v24 第 10 批:名册真性能(根因是每卡内联表单)等九条
- #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 实测)。
This commit is contained in:
@@ -13,3 +13,8 @@ uploads/*
|
|||||||
*~
|
*~
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
|
# ── 运行期缓存(节假日自动更新的落地处),不入库 ──────────────────────────────
|
||||||
|
# v24 第 10 批:节假日安排由后台自动抓取后缓存(storage/cache/holiday-<年>.json),
|
||||||
|
# 它是运行期产物、且每个环境各自一份,不该跟着代码走。目录本身由部署脚本创建。
|
||||||
|
storage/*
|
||||||
|
|||||||
+75
-2
@@ -22,12 +22,31 @@ final class Calendar
|
|||||||
/** 日程附件的宿主类型(统一附件表用;v24 第 6 条:日程也能挂附件) */
|
/** 日程附件的宿主类型(统一附件表用;v24 第 6 条:日程也能挂附件) */
|
||||||
public const ATTACH = 'calendar_event';
|
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 = [
|
public const DEFAULT_KINDS = [
|
||||||
'movie' => ['观影', '#7A4E8C'],
|
'movie' => ['观影', '#7A4E8C'],
|
||||||
'trip' => ['旅行', '#3F7D5A'],
|
'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' 之类直接比字符串(日历本颜色是给页面用的,
|
// 常量里的 '#7A4E8C' 之类直接比字符串(日历本颜色是给页面用的,
|
||||||
// 大小写混着存会让"颜色一样吗"这种判断时对时错)。
|
// 大小写混着存会让"颜色一样吗"这种判断时对时错)。
|
||||||
strtoupper(AvatarColor::normalize($color) ?: '#4A7C82'),
|
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),
|
self::nextPosition($ownerId),
|
||||||
]);
|
]);
|
||||||
return (int)$db->lastInsertId();
|
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(
|
$st = Database::get()->prepare(
|
||||||
"SELECT t.id, t.title, t.due_at, t.status
|
"SELECT t.id, t.title, t.due_at, t.status
|
||||||
|
|||||||
+562
@@ -0,0 +1,562 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* 法定节假日 / 调休 + 农历 —— 日程页两个**不可删除的只读日历**的数据源。
|
||||||
|
*
|
||||||
|
* v24 第 10 批 #4(用户原话):
|
||||||
|
* 「日程中,增加两个不可删除的日历,一个是农历,一个是法定假日的休息和调休信息,
|
||||||
|
* 法定假日这个,需要有自动更新功能,例如明年的放假调休情况定下来了,那么就要及时
|
||||||
|
* 更新进去,**不是让你更新,而是让系统,代码自动更新**。」
|
||||||
|
*
|
||||||
|
* 所以这里的核心不是"填一份 2026 年的数据",而是**把"自动更新"做成机制**:
|
||||||
|
*
|
||||||
|
* ┌─ 第一级:内置规则(离线可用、永不失效)
|
||||||
|
* │ 元旦 / 清明 / 劳动节 / 国庆 是**公历固定或节气**,可以直接推导;
|
||||||
|
* │ 春节 / 端午 / 中秋 由农历日期推导(Lunar 类,1900-2100 全覆盖)。
|
||||||
|
* │ → 即使断网、即使没有任何外部数据,每年也会自动"长出"这 7 个节日的放假日期。
|
||||||
|
* │ 只是"调休哪几天"官方没公布之前推不出来(那本来就是行政决定)。
|
||||||
|
* └─ 第二级:官方公布数据(自动补齐调休)
|
||||||
|
* 国务院办公厅每年 10-11 月公布次年安排。数据源用**公开的 JSON 镜像**
|
||||||
|
* (NateScarlet/holiday-cn,按年一个文件),抓到就写进库并缓存。
|
||||||
|
* → 这就是用户要的"明年的放假调休情况定下来了,系统自动更新进去"。
|
||||||
|
*
|
||||||
|
* ⚠️ 两级的关系是**互补而非替代**:拉取失败必须仍然有第一级的结果(只是少调休),
|
||||||
|
* 绝不能因为外部源挂了就让日历空掉——那样用户会以为"功能坏了"。
|
||||||
|
*
|
||||||
|
* ⚠️ 缓存放 settings 表(`holiday_cache_<年>`),而不是每次请求都去抓:
|
||||||
|
* 日历页是高频页面,把外网请求放渲染路径上会让它随外网抖动而变慢。
|
||||||
|
*/
|
||||||
|
|
||||||
|
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<string,array{name:string,off:bool}>}
|
||||||
|
* 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<int,bool> 本次请求已经排过队的年份(避免重复注册 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<int,array{date:string,title:string,name:string,off:bool}>
|
||||||
|
*/
|
||||||
|
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';
|
||||||
|
}
|
||||||
|
}
|
||||||
+104
-42
@@ -38,6 +38,12 @@ final class Ledger
|
|||||||
'cash' => ['现金', 'asset', '💵'],
|
'cash' => ['现金', 'asset', '💵'],
|
||||||
'loan' => ['贷款', 'debt', '📉'],
|
'loan' => ['贷款', 'debt', '📉'],
|
||||||
'credit' => ['信用卡', 'debt', '💳'],
|
'credit' => ['信用卡', 'debt', '💳'],
|
||||||
|
// v24 第 10 批 #2(用户原话):「类别,增加一个欠款,用来记录别人的钱,
|
||||||
|
// 属于负债,是负的。」
|
||||||
|
// 与「贷款/信用卡」的区别:那两样是欠**机构**的钱,这个是欠**个人**的
|
||||||
|
// (借了亲戚朋友的钱、还没还的份子钱…)。同属负债,所以一起计入「总负债」,
|
||||||
|
// 净资产照旧 = 资产 − 负债。
|
||||||
|
'owe' => ['欠款', 'debt', '🤝'],
|
||||||
'other' => ['其它', 'asset', '📦'],
|
'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<string,int>,count:int}
|
* @return array{asset:int,debt:int,net:int,by_kind:array<string,int>,count:int}
|
||||||
*/
|
*/
|
||||||
public static function summary(array $entries): array
|
public static function summary(array $entries): array
|
||||||
@@ -116,11 +130,10 @@ final class Ledger
|
|||||||
foreach ($entries as $e) {
|
foreach ($entries as $e) {
|
||||||
$amt = (int)($e['amount_cents'] ?? 0);
|
$amt = (int)($e['amount_cents'] ?? 0);
|
||||||
$kind = (string)($e['kind'] ?? 'other');
|
$kind = (string)($e['kind'] ?? 'other');
|
||||||
$isDebt = (self::KINDS[$kind][1] ?? 'asset') === 'debt';
|
if (self::isDebt($kind)) {
|
||||||
if ($isDebt) {
|
$debt += abs($amt); // 见方法注释:负数输入也要被算成负债
|
||||||
$debt += $amt;
|
|
||||||
} else {
|
} else {
|
||||||
$asset += $amt;
|
$asset += $amt; // 资产保持符号原样(透支=负资产是合理的)
|
||||||
}
|
}
|
||||||
$byKind[$kind] = ($byKind[$kind] ?? 0) + $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 */
|
/** @return int 新账本 id */
|
||||||
@@ -232,14 +257,16 @@ final class Ledger
|
|||||||
public static function addEntry(int $ledgerId, array $d, int $byId = 0): int
|
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(
|
$db->prepare(
|
||||||
'INSERT INTO ledger_entries (ledger_id, kind, title, amount_cents, note, person_id, created_by)
|
'INSERT INTO ledger_entries (ledger_id, kind, title, amount_cents, note, person_id, created_by)
|
||||||
VALUES (?,?,?,?,?,?,?)'
|
VALUES (?,?,?,?,?,?,?)'
|
||||||
)->execute([
|
)->execute([
|
||||||
$ledgerId,
|
$ledgerId,
|
||||||
self::kind($d['kind'] ?? ''),
|
$knd,
|
||||||
mb_substr(trim((string)($d['title'] ?? '')), 0, 120, 'UTF-8'),
|
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,
|
trim((string)($d['note'] ?? '')) ?: null,
|
||||||
(int)($d['person_id'] ?? 0) ?: null,
|
(int)($d['person_id'] ?? 0) ?: null,
|
||||||
$byId ?: null,
|
$byId ?: null,
|
||||||
@@ -249,13 +276,14 @@ final class Ledger
|
|||||||
|
|
||||||
public static function updateEntry(int $ledgerId, int $id, array $d): void
|
public static function updateEntry(int $ledgerId, int $id, array $d): void
|
||||||
{
|
{
|
||||||
|
$knd = self::kind($d['kind'] ?? '');
|
||||||
Database::get()->prepare(
|
Database::get()->prepare(
|
||||||
'UPDATE ledger_entries SET kind=?, title=?, amount_cents=?, note=?, person_id=?
|
'UPDATE ledger_entries SET kind=?, title=?, amount_cents=?, note=?, person_id=?
|
||||||
WHERE id=? AND ledger_id=?'
|
WHERE id=? AND ledger_id=?'
|
||||||
)->execute([
|
)->execute([
|
||||||
self::kind($d['kind'] ?? ''),
|
$knd,
|
||||||
mb_substr(trim((string)($d['title'] ?? '')), 0, 120, 'UTF-8'),
|
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,
|
trim((string)($d['note'] ?? '')) ?: null,
|
||||||
(int)($d['person_id'] ?? 0) ?: null,
|
(int)($d['person_id'] ?? 0) ?: null,
|
||||||
$id, $ledgerId,
|
$id, $ledgerId,
|
||||||
@@ -362,12 +390,14 @@ final class Ledger
|
|||||||
public static function reconcile(int $ledgerId, int $entryId, $amount, string $note = '', int $byId = 0): bool
|
public static function reconcile(int $ledgerId, int $entryId, $amount, string $note = '', int $byId = 0): bool
|
||||||
{
|
{
|
||||||
$db = Database::get();
|
$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]);
|
$st->execute([$entryId, $ledgerId]);
|
||||||
if ($st->fetchColumn() === false) {
|
$row = $st->fetch();
|
||||||
|
if ($row === false) {
|
||||||
return 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 = ?')
|
$db->prepare('UPDATE ledger_entries SET amount_cents = ? WHERE id = ? AND ledger_id = ?')
|
||||||
->execute([$cents, $entryId, $ledgerId]);
|
->execute([$cents, $entryId, $ledgerId]);
|
||||||
$db->prepare(
|
$db->prepare(
|
||||||
@@ -395,15 +425,19 @@ final class Ledger
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 这本账的**汇总时间线**:每个盘点日,整本账的净资产是多少。
|
* 这本账的**汇总时间线**:每个时点,整本账的净资产是多少。
|
||||||
*
|
*
|
||||||
* 做法:把每个账目在每个时点上"当时的值"取出来再加总 ——
|
* v24 第 10 批 #3 修正:时点不再只取"盘点过的日期"。
|
||||||
* · 某账目在 t 之前从未盘点过 → 它的值是**它现在的值**(因为一直没变过);
|
* ⚠️ 只取盘点日期有个致命的冷启动问题:用户**第一次盘点时只有一个时点**,
|
||||||
* · 盘点过 → 取 t 之前最后一次盘点的值。
|
* 模板的 `count($timeline) > 1` 不成立 → 图根本不显示。而那一刻恰恰是他
|
||||||
* 这样"只盘过招行"也能画出一条合理的总资产曲线,
|
* 最想看到图的时刻("我盘点了,图怎么还没出来")。
|
||||||
* 而不是"没盘过的账目在图上凭空消失"。
|
* 所以时点集合改成三者并集:① 账目**创建日**(那时它开始存在)
|
||||||
|
* ② 所有盘点日 ③ 今天。于是"今天记了三笔账"就能画出一条 0 → 逐步累加的线。
|
||||||
*
|
*
|
||||||
* @return array<int,array{date:string,net:int}> 至少一条(今天);空账本返回 []
|
* 取值规则(每个时点 t):账目在 t 之后才创建 → **不计入**(那时它还不存在);
|
||||||
|
* 在 t 之前盘点过 → 取最后一次盘点的值;从未盘点过 → 取它现在的值(一直没变过)。
|
||||||
|
*
|
||||||
|
* @return array<int,array{date:string,net:int,asset:int,debt:int}> 空账本返回 []
|
||||||
*/
|
*/
|
||||||
public static function timeline(int $ledgerId): array
|
public static function timeline(int $ledgerId): array
|
||||||
{
|
{
|
||||||
@@ -421,15 +455,43 @@ final class Ledger
|
|||||||
foreach ($st->fetchAll() as $r) {
|
foreach ($st->fetchAll() as $r) {
|
||||||
$dates[] = (string)$r['d'];
|
$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');
|
$today = date('Y-m-d');
|
||||||
$dates[] = $today; // 永远带上"现在"
|
$dates[] = $today; // 永远带上"现在"
|
||||||
$dates = array_values(array_unique($dates));
|
$dates = array_values(array_unique($dates));
|
||||||
sort($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 = [];
|
$snaps = [];
|
||||||
|
$born = [];
|
||||||
|
$today10 = date('Y-m-d');
|
||||||
foreach ($entries as $e) {
|
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(
|
$st = Database::get()->prepare(
|
||||||
'SELECT entry_id, DATE(created_at) AS d, amount_cents
|
'SELECT entry_id, DATE(created_at) AS d, amount_cents
|
||||||
@@ -450,6 +512,10 @@ final class Ledger
|
|||||||
$debt = 0;
|
$debt = 0;
|
||||||
foreach ($entries as $e) {
|
foreach ($entries as $e) {
|
||||||
$eid = (int)$e['id'];
|
$eid = (int)$e['id'];
|
||||||
|
// 这个时点上它还不存在(还没记进来)→ 不计入,否则等于凭空多钱
|
||||||
|
if ($d < $born[$eid]) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
$val = (int)$e['amount_cents']; // 默认:一直没变过 = 还是现在这个值
|
$val = (int)$e['amount_cents']; // 默认:一直没变过 = 还是现在这个值
|
||||||
foreach ($snaps[$eid] as [$sd, $sv]) {
|
foreach ($snaps[$eid] as [$sd, $sv]) {
|
||||||
if ($sd <= $d) {
|
if ($sd <= $d) {
|
||||||
@@ -459,7 +525,8 @@ final class Ledger
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (self::isDebt((string)$e['kind'])) {
|
if (self::isDebt((string)$e['kind'])) {
|
||||||
$debt += $val;
|
// ⚠️ 与 summary 同一口径:负债取绝对值(老数据里可能是被判反的负数)
|
||||||
|
$debt += abs($val);
|
||||||
} else {
|
} else {
|
||||||
$asset += $val;
|
$asset += $val;
|
||||||
}
|
}
|
||||||
@@ -490,34 +557,29 @@ final class Ledger
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 这本账里**有盘点历史**的账目(列表上方只给这几条画线)。
|
* 这本账里**每条账目**的曲线(列表上方逐条画)。
|
||||||
* 全画会变成一坨面条 —— 用户要的是"招商银行每年的金额变化"这种单条曲线。
|
*
|
||||||
|
* v24 第 10 批 #3(用户原话):「在账目上面,增加一个图表显示,**显示所有账目和
|
||||||
|
* 总金额的变化曲线**,根据盘点的金额进行显示。横轴就是时间,纵轴是金额,用折线图。」
|
||||||
|
*
|
||||||
|
* ⚠️ 这里与 v24 第 8 条的旧实现有个**语义修正**:
|
||||||
|
* 旧版只画「有盘点记录的账目」(当时担心"全画会变成一坨面条")。
|
||||||
|
* 但用户这次明确要求「**所有**账目」,所以改成**每条都出线**,
|
||||||
|
* "面条"问题改由**前端折叠 + 按类别筛选**解决(见模板里的曲线选择条),
|
||||||
|
* 而不是靠"少画几条"偷偷减少信息。
|
||||||
|
* 没盘点过的账目会得到一条**单点曲线**(就是它现在的值),
|
||||||
|
* 图上画一个点而不是空白 —— 空白会被误读成"这条账没数据"。
|
||||||
*
|
*
|
||||||
* @return array<int,array{entry:array,series:array}>
|
* @return array<int,array{entry:array,series:array}>
|
||||||
*/
|
*/
|
||||||
public static function charts(int $ledgerId): 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 = [];
|
$out = [];
|
||||||
foreach ($ids as $eid) {
|
foreach (self::entries($ledgerId) as $e) {
|
||||||
if (!isset($byId[$eid])) {
|
$eid = (int)$e['id'];
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$out[] = [
|
$out[] = [
|
||||||
'entry' => $byId[$eid],
|
'entry' => $e,
|
||||||
'series' => self::entrySeries($eid, (int)$byId[$eid]['amount_cents']),
|
'series' => self::entrySeries($eid, (int)$e['amount_cents']),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
return $out;
|
return $out;
|
||||||
|
|||||||
+45
-2
@@ -71,7 +71,7 @@ final class Schema
|
|||||||
* ⚠️ 它**不再**作为"要不要跑升级"的判据 —— 见 fingerprint() 的说明。
|
* ⚠️ 它**不再**作为"要不要跑升级"的判据 —— 见 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 '这本日历是谁的',
|
`owner_id` INT UNSIGNED NOT NULL COMMENT '这本日历是谁的',
|
||||||
`name` VARCHAR(50) NOT NULL,
|
`name` VARCHAR(50) NOT NULL,
|
||||||
`color` CHAR(7) NOT NULL DEFAULT '#4A7C82' COMMENT '日历本的颜色(日程按本子的颜色显示)',
|
`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,
|
`position` INT NOT NULL DEFAULT 100,
|
||||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
PRIMARY KEY (`id`),
|
PRIMARY KEY (`id`),
|
||||||
@@ -850,6 +850,49 @@ SQL,
|
|||||||
self::tryStep('老地址拆成国家/省/市/区', static function () use ($db) {
|
self::tryStep('老地址拆成国家/省/市/区', static function () use ($db) {
|
||||||
self::backfillAddress($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 之前传的)补算一次:回读已存的图算主色。
|
// 老头像(v11 之前传的)补算一次:回读已存的图算主色。
|
||||||
// 纯数据步骤 → 容错跑:某张图读不回来(OSS 权限、对象丢了)只影响那一个人,
|
// 纯数据步骤 → 容错跑:某张图读不回来(OSS 权限、对象丢了)只影响那一个人,
|
||||||
// 他会自动落到「按姓名取色」的兜底上,不该把后面的升级步骤挡住。
|
// 他会自动落到「按姓名取色」的兜底上,不该把后面的升级步骤挡住。
|
||||||
|
|||||||
+92
-14
@@ -542,6 +542,26 @@ textarea.input { min-height: 76px; resize: vertical; }
|
|||||||
/* 工作经历的月份选择器(type="month" 升级):占满原 .job-cell 格子 */
|
/* 工作经历的月份选择器(type="month" 升级):占满原 .job-cell 格子 */
|
||||||
.dt-semi.dt-semi-month { display: none; }
|
.dt-semi.dt-semi-month { display: none; }
|
||||||
.dt-semi.dt-semi-month.is-live { display: block; width: 100%; }
|
.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; }
|
.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 之后,
|
/* Semi 主题对齐宣纸色。semi.min.css 是懒加载的、必然排在 app.css 之后,
|
||||||
所以这里赢在**优先级**:body[class] 特异性(0,1,1) > semi 的 body(0,0,1)。 */
|
所以这里赢在**优先级**: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 {
|
.person-edit {
|
||||||
padding: 3px 10px; font-size: 13px; white-space: nowrap;
|
padding: 3px 10px; font-size: 13px; white-space: nowrap;
|
||||||
}
|
}
|
||||||
.person-more > summary {
|
.person-more-btn {
|
||||||
list-style: none; cursor: pointer; padding: 4px 10px;
|
padding: 3px 9px; font-size: 15px; line-height: 1.3;
|
||||||
font-size: 16px; color: var(--muted); border-radius: 8px;
|
color: var(--muted); white-space: nowrap;
|
||||||
}
|
}
|
||||||
.person-more > summary:hover { background: var(--line-2); color: var(--brand); }
|
.person-more-btn:hover { color: var(--brand); }
|
||||||
.person-more > summary::-webkit-details-marker { display: none; }
|
|
||||||
.person-more > summary::after { content: ' ▾'; }
|
/* ---------------- 名册卡片动作的**共享浮层**(v24 第 10 批 #6)----------------
|
||||||
.person-more[open] > summary::after { content: ' ▴'; }
|
「分组」「移出名册」两份表单全页只渲染一份(原来每卡各一份,1184 人 =
|
||||||
.person-more > summary:hover { color: var(--brand); }
|
2369 个 form / 27234 个 input,密码管理器逐个扫描把页面拖死)。
|
||||||
.person-more[open] {
|
这里给共享浮层一个定位容器,面板由 JS 贴合触发按钮摆放。 */
|
||||||
position: absolute; right: 8px; top: 8px; transform: none;
|
.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);
|
background: var(--surface); border: 1px solid var(--line);
|
||||||
border-radius: 10px; box-shadow: var(--shadow-l); z-index: 30;
|
border-radius: 10px; box-shadow: var(--shadow-l);
|
||||||
min-width: 232px; padding-bottom: 4px;
|
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 { 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 .select { flex: 1 1 auto; min-width: 0; padding: 6px 8px; font-size: 13px; }
|
||||||
.person-move .btn { flex: 0 0 auto; }
|
.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 { display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; margin-bottom: 4px; }
|
||||||
.ck-head b { font-size: 14px; }
|
.ck-head b { font-size: 14px; }
|
||||||
.ck-svg { display: block; margin-top: 2px; }
|
.ck-svg { display: block; margin-top: 2px; }
|
||||||
/* 盘点面板:与「更正」并列在一个 <details> 里,中间给一条分隔线才分得清两件事 */
|
|
||||||
.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 {
|
||||||
|
/* 作为 <tr> 的兄弟插进来(见模板里的 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-form-h { font-size: 12.5px; color: var(--muted); font-weight: 500; margin: 0 0 8px; }
|
||||||
.led-hist { margin-top: 6px; }
|
.led-hist { margin-top: 6px; }
|
||||||
.led-hist summary { cursor: pointer; }
|
.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;
|
background: var(--brand); border-color: var(--brand); color: #fff; font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---- v24 第 10 批 #6:日程页头的「跳到某一天」按钮 ----
|
||||||
|
外观与 .btn 一致(它是 <label> 包着一个原生 date 输入,所以自己画外壳)。
|
||||||
|
原生日历指示器收掉:整个按钮都是可点的,多一个小图标反而抢视线。 */
|
||||||
|
.cal-jump { display: inline-flex; margin: 0; }
|
||||||
|
.cal-jump-btn {
|
||||||
|
display: inline-flex; align-items: center; gap: 6px;
|
||||||
|
cursor: pointer; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.cal-jump-btn:hover { border-color: var(--brand-2); }
|
||||||
|
.cal-jump-txt { font-size: 13px; }
|
||||||
|
.cal-jump-date {
|
||||||
|
border: 0; background: transparent; font: inherit; font-size: 13px;
|
||||||
|
color: var(--ink); cursor: pointer; padding: 0; min-width: 118px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.cal-jump-date::-webkit-calendar-picker-indicator { cursor: pointer; opacity: .55; }
|
||||||
|
.cal-jump-date:focus { outline: none; }
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
/* 窄屏:只留图标 + 日期,把「跳到」两个字收掉(不然页头一排按钮换行) */
|
||||||
|
.cal-jump-txt { display: none; }
|
||||||
|
.cal-jump-date { min-width: 104px; }
|
||||||
|
}
|
||||||
|
|
||||||
/* ==================== 周 / 天:按小时铺的时间网格 ==================== */
|
/* ==================== 周 / 天:按小时铺的时间网格 ==================== */
|
||||||
/* 结构:左边一条"小时刻度"沟槽(tg-gutter),右边 N 个日期列(tg-col)。
|
/* 结构:左边一条"小时刻度"沟槽(tg-gutter),右边 N 个日期列(tg-col)。
|
||||||
列高固定 = 24 小时 × 每小时 44px,日程块按 top/height 百分比**绝对定位**,
|
列高固定 = 24 小时 × 每小时 44px,日程块按 top/height 百分比**绝对定位**,
|
||||||
|
|||||||
+219
-47
@@ -786,21 +786,12 @@
|
|||||||
})();
|
})();
|
||||||
|
|
||||||
// ===== 名册行的「分组」按钮:选中即提交 =====
|
// ===== 名册行的「分组」按钮:选中即提交 =====
|
||||||
// 用事件委托:名册有几行就有几个这种表单,而且以后动态加进来的一样生效。
|
// ⚠️ v24 第 10 批 #6 起,**这段委托搬进了 templates/people/index.php 的页内脚本**。
|
||||||
// ⚠️ 只是"选中即提交"的锦上添花 —— 表单里那个「保存」按钮一直都在,
|
// 原因:分组表单不再是"每卡一份"的内联 <form>,而是全页**唯一一份**共享浮层
|
||||||
// 没有 JS(或 JS 出错)时照样能改分组,不会变成"点了没反应"。
|
// (1184 人的名册原来有 2369 个 form / 27234 个 input,密码管理器逐个扫描
|
||||||
document.addEventListener('change', function (e) {
|
// 会把页面拖死 —— 用户报的"和 Bitwarden 冲突"就是这个)。
|
||||||
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; }
|
|
||||||
// requestSubmit 才会跑浏览器的原生校验;老浏览器退回 submit()
|
|
||||||
if (typeof form.requestSubmit === 'function') {
|
|
||||||
form.requestSubmit();
|
|
||||||
} else {
|
|
||||||
form.submit();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ===== 点页面其它地方 / 按 Esc 就收起下拉菜单 =====
|
// ===== 点页面其它地方 / 按 Esc 就收起下拉菜单 =====
|
||||||
// 用户 2026-10-03 第 3 条:「菜单栏点击后面,应该自动关闭,不应该一直保持打开」。
|
// 用户 2026-10-03 第 3 条:「菜单栏点击后面,应该自动关闭,不应该一直保持打开」。
|
||||||
@@ -905,26 +896,12 @@
|
|||||||
return n ? String(n.value || '') : '';
|
return n ? String(n.value || '') : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function upgrade(box) {
|
/**
|
||||||
if (box.getAttribute('data-semi-done')) { return; }
|
* 把 Semi 给的字符串写回某个 .dt-input 的原生四字段(POST 契约)。
|
||||||
if (typeof window.SemiUI === 'undefined' || !window.SemiUI.DatePicker) { return; }
|
* str 形如 '2026-10-18 07:46:13' / '2026-10-18 07:46' / ''(清空)。
|
||||||
if (!window.ReactDOM || !window.ReactDOM.createRoot) { return; }
|
* ⚠️ 提成公用函数:单点控件与范围两端都走它,规则只有一处。
|
||||||
var cfg = {};
|
*/
|
||||||
try { cfg = JSON.parse(box.getAttribute('data-dt') || '{}'); } catch (e) { /* 空配置按默认走 */ }
|
function writeDT(box, str) {
|
||||||
var host = box.querySelector('.dt-semi');
|
|
||||||
var native = box.querySelector('.dt-native');
|
|
||||||
if (!host || !native) { return; }
|
|
||||||
|
|
||||||
var fmt = 'yyyy-MM-dd HH:mm' + (cfg.seconds === false ? '' : ':ss');
|
|
||||||
var init = parseDT(
|
|
||||||
part(native, '.dt-date') + ' ' +
|
|
||||||
(part(native, '.dt-hour') || '00') + ':' +
|
|
||||||
(part(native, '.dt-minute') || '00') + ':' +
|
|
||||||
(part(native, '.dt-second') || '00')
|
|
||||||
);
|
|
||||||
|
|
||||||
// Semi 的 onChange → 写回原生四字段(POST 契约),并派 change 事件给可能的监听者
|
|
||||||
function writeBack(date, str) {
|
|
||||||
var m = (str || '').trim().match(/^(\d{4})-(\d{1,2})-(\d{1,2})[ T](\d{1,2}):(\d{1,2})(?::(\d{1,2}))?$/);
|
var m = (str || '').trim().match(/^(\d{4})-(\d{1,2})-(\d{1,2})[ T](\d{1,2}):(\d{1,2})(?::(\d{1,2}))?$/);
|
||||||
function set(sel, v) {
|
function set(sel, v) {
|
||||||
var n = box.querySelector(sel);
|
var n = box.querySelector(sel);
|
||||||
@@ -945,32 +922,171 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 读某个 .dt-input 当前的原生值 → 'YYYY-MM-DD HH:mm:ss' */
|
||||||
|
function readDT(box) {
|
||||||
|
var native = box.querySelector('.dt-native');
|
||||||
|
if (!native) { return ''; }
|
||||||
|
return part(native, '.dt-date') + ' ' +
|
||||||
|
(part(native, '.dt-hour') || '00') + ':' +
|
||||||
|
(part(native, '.dt-minute') || '00') + ':' +
|
||||||
|
(part(native, '.dt-second') || '00');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 起止范围:把一对 .dt-input[data-dt.range=start|end] 合并成**一个**
|
||||||
|
* Semi `dateTimeRange` 选择器(用户第 7 条:「应该用这个日期插件的时间范围选择方式,
|
||||||
|
* 而不是将这个范围拆成两个,这样不好看,也多一步操作」)。
|
||||||
|
*
|
||||||
|
* 契约不变:两个原生控件都留着(藏在隐藏容器里),Semi 选完把两端分别写回它们,
|
||||||
|
* 所以服务端的 read_dt_input('starts_at') / ('ends_at') 与既有的 e2e 全部照旧。
|
||||||
|
*/
|
||||||
|
function upgradeRange(group) {
|
||||||
|
if (group.getAttribute('data-semi-range-done')) { return; }
|
||||||
|
var boxes = Array.prototype.slice.call(group.querySelectorAll('.dt-input[data-dt]'));
|
||||||
|
if (boxes.length !== 2) { return; }
|
||||||
|
// ⚠️ **两端都得可见**才升级成范围控件。
|
||||||
|
// 日程页的「结束」默认是隐藏的(要点「+ 添加结束时间」才出现,见 _event_fields.php)——
|
||||||
|
// 那时用户表达的意图是"就一个时刻",硬给一个范围控件反而多一个空着的输入框。
|
||||||
|
// 用户在页面里点了那个开关之后,页内 JS 会再调一次 window.fbUpgradeRange(),
|
||||||
|
// 这时两端都可见了,才真正合并成范围。
|
||||||
|
var visible = boxes.every(function (bx) { return bx.offsetParent !== null; });
|
||||||
|
if (!visible) { return; }
|
||||||
|
// 按 data-dt.range 认起止;没标就按 DOM 顺序(起在前)
|
||||||
|
var a = null, b = null, secs = true;
|
||||||
|
boxes.forEach(function (bx) {
|
||||||
|
var cfg = {};
|
||||||
|
try { cfg = JSON.parse(bx.getAttribute('data-dt') || '{}'); } catch (e) {}
|
||||||
|
if (cfg.seconds === false) { secs = false; }
|
||||||
|
if (cfg.range === 'end') { b = bx; } else if (cfg.range === 'start') { a = bx; }
|
||||||
|
else if (a === null) { a = bx; } else { b = bx; }
|
||||||
|
});
|
||||||
|
if (!a || !b) { return; }
|
||||||
|
if (typeof window.SemiUI === 'undefined' || !window.SemiUI.DatePicker) { return; }
|
||||||
|
if (!window.ReactDOM || !window.ReactDOM.createRoot) { return; }
|
||||||
|
|
||||||
|
var host = document.createElement('span');
|
||||||
|
host.className = 'dt-semi dt-semi-range';
|
||||||
|
// 插到这一对控件的**前面**(原生那对随后藏起来)
|
||||||
|
group.insertBefore(host, group.firstChild);
|
||||||
|
|
||||||
|
var fmt = 'yyyy-MM-dd HH:mm' + (secs ? ':ss' : '');
|
||||||
|
|
||||||
|
function onRange(dates, strs) {
|
||||||
|
var arr = Array.isArray(strs) ? strs : [strs];
|
||||||
|
writeDT(a, arr[0] || '');
|
||||||
|
writeDT(b, arr[1] || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 同上:先 unmount 再 createRoot,否则 defaultValue 改了也不生效
|
||||||
|
var rroot = null;
|
||||||
|
function mountRange() {
|
||||||
|
if (rroot) { rroot.unmount(); rroot = null; }
|
||||||
|
host.innerHTML = '';
|
||||||
|
var q0 = parseDT(readDT(a)), q1 = parseDT(readDT(b));
|
||||||
|
rroot = ReactDOM.createRoot(host);
|
||||||
|
rroot.render(React.createElement(SemiUI.DatePicker, {
|
||||||
|
type: 'dateTimeRange',
|
||||||
|
format: fmt,
|
||||||
|
showClear: true,
|
||||||
|
defaultValue: (q0 || q1) ? [q0 || q1, q1 || q0] : undefined,
|
||||||
|
placeholder: ['开始时间', '结束时间'],
|
||||||
|
position: window.matchMedia('(max-width: 640px)').matches ? 'bottom' : 'bottomLeft',
|
||||||
|
onChange: onRange
|
||||||
|
}));
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
ReactDOM.createRoot(host).render(React.createElement(SemiUI.DatePicker, {
|
mountRange();
|
||||||
|
} catch (e) {
|
||||||
|
if (window.console) { console.warn('Semi 范围选择器挂载失败,留在原生控件:', e && e.message); }
|
||||||
|
host.parentNode.removeChild(host);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
group.__fbRemount = mountRange;
|
||||||
|
|
||||||
|
a.style.display = 'none';
|
||||||
|
b.style.display = 'none';
|
||||||
|
// ⚠️ 范围里的两端同样要摘 required(隐藏的 required 会让整个表单提交不了)
|
||||||
|
guardRequired(a);
|
||||||
|
guardRequired(b);
|
||||||
|
group.setAttribute('data-semi-range-done', '1');
|
||||||
|
group.classList.add('is-semi-range');
|
||||||
|
}
|
||||||
|
|
||||||
|
function upgrade(box) {
|
||||||
|
if (box.getAttribute('data-semi-done')) { return; }
|
||||||
|
// 范围对的成员由 upgradeRange 统一处理(一个控件管两端,用户第 7 条)
|
||||||
|
if (box.classList.contains('dt-part')) { return; }
|
||||||
|
if (typeof window.SemiUI === 'undefined' || !window.SemiUI.DatePicker) { return; }
|
||||||
|
if (!window.ReactDOM || !window.ReactDOM.createRoot) { return; }
|
||||||
|
var cfg = {};
|
||||||
|
try { cfg = JSON.parse(box.getAttribute('data-dt') || '{}'); } catch (e) { /* 空配置按默认走 */ }
|
||||||
|
var host = box.querySelector('.dt-semi');
|
||||||
|
var native = box.querySelector('.dt-native');
|
||||||
|
if (!host || !native) { return; }
|
||||||
|
|
||||||
|
var fmt = 'yyyy-MM-dd HH:mm' + (cfg.seconds === false ? '' : ':ss');
|
||||||
|
var init = parseDT(readDT(box));
|
||||||
|
|
||||||
|
// Semi 的 onChange → 写回原生四字段(POST 契约),并派 change 事件给可能的监听者
|
||||||
|
function writeBack(date, str) { writeDT(box, str); }
|
||||||
|
|
||||||
|
// ⚠️ 重挂必须**先 unmount 再 createRoot**,不能对同一个 root 再 render:
|
||||||
|
// DatePicker 是非受控组件(我们用 defaultValue),React 18 复用旧 root 时
|
||||||
|
// 新传的 defaultValue **不会**改变已显示的值 —— 于是 fbSetDT 改了原生字段、
|
||||||
|
// Semi 还显示旧值("点了 15:00 控件里还写 09:00"的真实成因)。
|
||||||
|
var root = null;
|
||||||
|
function mount() {
|
||||||
|
if (root) { root.unmount(); root = null; }
|
||||||
|
host.innerHTML = '';
|
||||||
|
root = ReactDOM.createRoot(host);
|
||||||
|
root.render(React.createElement(SemiUI.DatePicker, {
|
||||||
type: 'dateTime',
|
type: 'dateTime',
|
||||||
format: fmt,
|
format: fmt,
|
||||||
showClear: true,
|
showClear: true,
|
||||||
defaultValue: init,
|
defaultValue: parseDT(readDT(box)),
|
||||||
placeholder: '选择日期时间',
|
placeholder: '选择日期时间',
|
||||||
position: window.matchMedia('(max-width: 640px)').matches ? 'bottom' : 'bottomLeft',
|
position: window.matchMedia('(max-width: 640px)').matches ? 'bottom' : 'bottomLeft',
|
||||||
onChange: writeBack
|
onChange: writeBack
|
||||||
}));
|
}));
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
mount();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (window.console) { console.warn('Semi DatePicker 挂载失败,留在原生控件:', e && e.message); }
|
if (window.console) { console.warn('Semi DatePicker 挂载失败,留在原生控件:', e && e.message); }
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// 页内 JS 可能会**程序化**改这个字段(如日程页「点日历空白处」预填开始时间)——
|
||||||
|
// 原生值改了 Semi 却还显示旧值,两边就不一致了。所以把重挂函数挂到元素上,
|
||||||
|
// 供 window.fbSetDT() 调用(见文件末尾)。
|
||||||
|
box.__fbRemount = mount;
|
||||||
|
|
||||||
box.classList.add('is-semi');
|
box.classList.add('is-semi');
|
||||||
native.style.display = 'none';
|
native.style.display = 'none';
|
||||||
if (init) { box.classList.add('has-value'); }
|
if (init) { box.classList.add('has-value'); }
|
||||||
// required 不能留在 display:none 的输入上(Chrome 会拦提交却不显示提示),
|
guardRequired(box);
|
||||||
// 改成提交前自己查一遍。
|
box.setAttribute('data-semi-done', '1');
|
||||||
var req = native.querySelector('.dt-date[required]');
|
}
|
||||||
if (req) {
|
|
||||||
|
/**
|
||||||
|
* 原生控件被藏起来之后,它上面的 `required` 必须**摘掉**并改由我们自己校验。
|
||||||
|
*
|
||||||
|
* ⚠️ 这是 Chrome 的一个硬行为:`required` 落在 `display:none` 的控件上时,
|
||||||
|
* 表单提交会被拦住,但浏览器**没法把焦点移到那个元素**去提示 →
|
||||||
|
* 控制台报 "An invalid form control with name='…' is not focusable",
|
||||||
|
* 页面上**什么都不发生**(点了保存没反应,也没有红字提示)。
|
||||||
|
* 用户会以为"保存按钮坏了"。
|
||||||
|
*
|
||||||
|
* 所以:摘掉 required → 给容器打 data-required → 在 form 的 submit 上自查。
|
||||||
|
* 🔴 单点控件和**范围控件**都要走这里 —— 范围那次我漏了,
|
||||||
|
* 结果观影表单(starts_at 是必填)怎么点都提交不了。
|
||||||
|
*/
|
||||||
|
function guardRequired(box) {
|
||||||
|
var req = box.querySelector('.dt-date[required]');
|
||||||
|
if (!req) { return; }
|
||||||
req.removeAttribute('required');
|
req.removeAttribute('required');
|
||||||
box.setAttribute('data-required', '1');
|
box.setAttribute('data-required', '1');
|
||||||
var form = box.closest('form');
|
var form = box.closest('form');
|
||||||
if (form && !form.getAttribute('data-dt-guard')) {
|
if (!form || form.getAttribute('data-dt-guard')) { return; }
|
||||||
form.setAttribute('data-dt-guard', '1');
|
form.setAttribute('data-dt-guard', '1');
|
||||||
form.addEventListener('submit', function (ev) {
|
form.addEventListener('submit', function (ev) {
|
||||||
// 不能用 :not([value]) 这类选择器:value 是动态写回的属性,得手动筛
|
// 不能用 :not([value]) 这类选择器:value 是动态写回的属性,得手动筛
|
||||||
@@ -987,9 +1103,6 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
box.setAttribute('data-semi-done', '1');
|
|
||||||
}
|
|
||||||
|
|
||||||
var kicked = false;
|
var kicked = false;
|
||||||
function boot() {
|
function boot() {
|
||||||
@@ -1000,9 +1113,12 @@
|
|||||||
.then(function () { return loadJs(conf.dom); })
|
.then(function () { return loadJs(conf.dom); })
|
||||||
.then(function () { return loadJs(conf.ui); })
|
.then(function () { return loadJs(conf.ui); })
|
||||||
.then(function () {
|
.then(function () {
|
||||||
|
// 先处理"起止成对"的(它们会各自挂一个范围控件并把自己藏起来),
|
||||||
|
// 再处理剩下的单点控件 —— 否则范围里的两个会先被当成两个独立控件升级掉。
|
||||||
|
Array.prototype.forEach.call(document.querySelectorAll('.dt-range'), upgradeRange);
|
||||||
boxes.forEach(upgrade);
|
boxes.forEach(upgrade);
|
||||||
upgradeAllMonths();
|
upgradeAllMonths();
|
||||||
watchNewMonths();
|
watchNewControls();
|
||||||
})
|
})
|
||||||
.catch(function (err) {
|
.catch(function (err) {
|
||||||
// 资源拉不动就留在原生控件上 —— 渐进增强的兜底,不弹任何打扰
|
// 资源拉不动就留在原生控件上 —— 渐进增强的兜底,不弹任何打扰
|
||||||
@@ -1054,18 +1170,74 @@
|
|||||||
Array.prototype.forEach.call(document.querySelectorAll('input[type="month"]'), upgradeMonth);
|
Array.prototype.forEach.call(document.querySelectorAll('input[type="month"]'), upgradeMonth);
|
||||||
}
|
}
|
||||||
|
|
||||||
function watchNewMonths() {
|
/**
|
||||||
|
* 动态新增的控件也要升级。
|
||||||
|
*
|
||||||
|
* 三种会"事后长出来"的:
|
||||||
|
* · 工作经历「+ 再加一段」→ 新的 input[type=month];
|
||||||
|
* · 旅程「+ 添加节点」→ 新的 .dt-range(成对起止);
|
||||||
|
* · 任何其它被 JS 插入的 .dt-input。
|
||||||
|
* ⚠️ 用一个 200ms 的去抖定时器:这些操作往往**一次插入一大片 DOM**
|
||||||
|
* (整段表单),逐个 MutationRecord 跑一遍会让升级逻辑反复扫全页。
|
||||||
|
*/
|
||||||
|
function watchNewControls() {
|
||||||
if (!window.MutationObserver) { return; }
|
if (!window.MutationObserver) { return; }
|
||||||
var timer = null;
|
var timer = null;
|
||||||
new MutationObserver(function () {
|
new MutationObserver(function () {
|
||||||
if (timer) { return; }
|
if (timer) { return; }
|
||||||
timer = setTimeout(function () {
|
timer = setTimeout(function () {
|
||||||
timer = null;
|
timer = null;
|
||||||
|
// 范围先于单点(范围里的两个不能被当成两个独立控件)
|
||||||
|
Array.prototype.forEach.call(document.querySelectorAll('.dt-range'), upgradeRange);
|
||||||
|
Array.prototype.forEach.call(
|
||||||
|
document.querySelectorAll('.dt-input[data-dt]'),
|
||||||
|
function (bx) { upgrade(bx); });
|
||||||
upgradeAllMonths();
|
upgradeAllMonths();
|
||||||
}, 200);
|
}, 200);
|
||||||
}).observe(document.body, { childList: true, subtree: true });
|
}).observe(document.body, { childList: true, subtree: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 对外接口:程序化设置某个日期时间字段,并**同步 Semi 的显示**。
|
||||||
|
*
|
||||||
|
* 为什么必须有它:日程页「点日历空白处 → 预填开始时间」是页内 JS 直接写原生
|
||||||
|
* `input.dt-date.value`。Semi 是非受控组件,原生值改了它不会跟着变 ——
|
||||||
|
* 于是用户会看到"我点了 15:00,控件里还写着 09:00",而提交上去却是 15:00,
|
||||||
|
* 是最难解释的一类不一致。
|
||||||
|
*
|
||||||
|
* 用法:window.fbSetDT('starts_at', '2026-10-05 15:00:00')
|
||||||
|
* 第二个参数传 '' 表示清空。
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* 对外接口:重新扫描并升级所有范围对(供页内 JS 在"结束时间被显示出来"之后调用)。
|
||||||
|
* 幂等:已升级过的组会被 data-semi-range-done 挡掉。
|
||||||
|
*/
|
||||||
|
window.fbUpgradeRange = function () {
|
||||||
|
Array.prototype.forEach.call(document.querySelectorAll('.dt-range'), upgradeRange);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
window.fbSetDT = function (name, str) {
|
||||||
|
if (!name) { return false; }
|
||||||
|
var box = null;
|
||||||
|
Array.prototype.forEach.call(document.querySelectorAll('.dt-input[data-dt]'), function (b) {
|
||||||
|
if (box) { return; }
|
||||||
|
var cfg = {};
|
||||||
|
try { cfg = JSON.parse(b.getAttribute('data-dt') || '{}'); } catch (e) {}
|
||||||
|
if (cfg.name === name) { box = b; }
|
||||||
|
});
|
||||||
|
if (!box) { return false; }
|
||||||
|
writeDT(box, str);
|
||||||
|
// 原生写完了 —— 现在把 Semi 一侧也刷新成同一个值
|
||||||
|
var group = box.closest ? box.closest('.dt-range') : null;
|
||||||
|
if (group && group.getAttribute('data-semi-range-done') && group.__fbRemount) {
|
||||||
|
group.__fbRemount();
|
||||||
|
} else if (box.__fbRemount) {
|
||||||
|
box.__fbRemount();
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
// 错开首屏:idle 时再拉 4 MB 资源,别挡首屏渲染
|
// 错开首屏:idle 时再拉 4 MB 资源,别挡首屏渲染
|
||||||
if ('requestIdleCallback' in window) { requestIdleCallback(boot, { timeout: 3000 }); }
|
if ('requestIdleCallback' in window) { requestIdleCallback(boot, { timeout: 3000 }); }
|
||||||
else { setTimeout(boot, 500); }
|
else { setTimeout(boot, 500); }
|
||||||
|
|||||||
@@ -371,11 +371,20 @@ function day_columns(string $anchor, int $span): array
|
|||||||
if ($ts === false) {
|
if ($ts === false) {
|
||||||
$ts = time();
|
$ts = time();
|
||||||
}
|
}
|
||||||
|
// ⚠️ v24 第 10 批 #5(用户报「日视图点后一天/前一天没反应」):
|
||||||
|
// 这里原来**无条件**把日期吸到"这一周的周一"—— 周视图要这样(7 列从周一排),
|
||||||
|
// 但天视图只有 1 列,吸过去就等于**永远显示那一周的周一**:
|
||||||
|
// d=2026-10-04(周日)→ 列 09-28;d=2026-09-30(周三)→ 列 09-28;
|
||||||
|
// 只有 d 本来就是周一时才"看着正常"。而页头标题用的是 $anchor(正确),
|
||||||
|
// 于是症状正是"标题变了、日程区域纹丝不动"。
|
||||||
|
$start = $ts;
|
||||||
|
if ($span > 1) {
|
||||||
$w = (int)date('w', $ts); // 0=周日 … 6=周六
|
$w = (int)date('w', $ts); // 0=周日 … 6=周六
|
||||||
$mon = strtotime('-' . (($w + 6) % 7) . ' days', $ts); // 周一开头
|
$start = strtotime('-' . (($w + 6) % 7) . ' days', $ts); // 周一开头(仅周视图)
|
||||||
|
}
|
||||||
$out = [];
|
$out = [];
|
||||||
for ($i = 0; $i < max(1, $span); $i++) {
|
for ($i = 0; $i < max(1, $span); $i++) {
|
||||||
$d = date('Y-m-d', strtotime('+' . $i . ' days', $mon));
|
$d = date('Y-m-d', strtotime('+' . $i . ' days', $start));
|
||||||
$out[] = [
|
$out[] = [
|
||||||
'date' => $d,
|
'date' => $d,
|
||||||
'w' => (int)date('w', strtotime($d)),
|
'w' => (int)date('w', strtotime($d)),
|
||||||
@@ -3390,6 +3399,14 @@ switch ($r) {
|
|||||||
$calId = (int)($_POST['calendar_id'] ?? 0);
|
$calId = (int)($_POST['calendar_id'] ?? 0);
|
||||||
// ⚠️ 走 mineOne 而不是 Calendar::find():要带 perm,共享本的可读写才判得对
|
// ⚠️ 走 mineOne 而不是 Calendar::find():要带 perm,共享本的可读写才判得对
|
||||||
$cal = Calendar::mineOne($familyId, $me, $calId);
|
$cal = Calendar::mineOne($familyId, $me, $calId);
|
||||||
|
// v24 第 10 批 #4:农历 / 节假日(还有观影 / 旅行)是**派生只读本**,
|
||||||
|
// 内容由别处算出来,不能往里写日程 —— 写了也不会显示(Calendar::month 只从
|
||||||
|
// kind='custom' 取 calendar_events),却会在库里留下一堆"看不见的日程"。
|
||||||
|
// 用户的要求是"不可删除",这里顺带把"不可写入"也钉死。
|
||||||
|
if ($cal && Calendar::isReadonlyKind((string)$cal['kind'])) {
|
||||||
|
flash('err', '「' . $cal['name'] . '」是系统日历,内容自动生成,不能往里加日程。');
|
||||||
|
redirect('calendar', ['view' => 'month', 'ym' => $ym]);
|
||||||
|
}
|
||||||
if (!$cal || !Calendar::canWrite($cal, $me)) {
|
if (!$cal || !Calendar::canWrite($cal, $me)) {
|
||||||
flash('err', '这本日历你没有写权限。');
|
flash('err', '这本日历你没有写权限。');
|
||||||
redirect('calendar', ['ym' => $ym]);
|
redirect('calendar', ['ym' => $ym]);
|
||||||
|
|||||||
+16
-3
@@ -23,6 +23,17 @@ $dtValue = $dtValue ?? null;
|
|||||||
$dtRequired = (bool)($dtRequired ?? false);
|
$dtRequired = (bool)($dtRequired ?? false);
|
||||||
$dtId = (string)($dtId ?? '');
|
$dtId = (string)($dtId ?? '');
|
||||||
$dtSeconds = (bool)($dtSeconds ?? true);
|
$dtSeconds = (bool)($dtSeconds ?? true);
|
||||||
|
/* 用户 2026-10-04 第 7 条原话:「所有的时间选择,都已更换新插件,但是有个问题,
|
||||||
|
如果是选择一个时间范围的情况,就是说,有开始时间,有结束时间,应该用这个日期插件的
|
||||||
|
时间范围选择方式,而不是将这个范围拆成两个,这样不好看,也多一步操作。」
|
||||||
|
|
||||||
|
→ 有起止两端的场景不再并列两个控件,而是**一个范围控件**。
|
||||||
|
实现方式是"服务端照旧渲染两个原生控件(表单契约/e2e 都不动),
|
||||||
|
由 app.js 在升级时识别成一对、只挂一个 Semi 范围选择器"。所以这里只加标记:
|
||||||
|
$dtRange = 'start' | 'end' —— 这对控件属于同一个范围(调用方还要用
|
||||||
|
<div class="dt-range"> 把两者包在一起,见 movies/form.php 等)。
|
||||||
|
留空 = 普通单点控件(默认,行为完全不变)。 */
|
||||||
|
$dtRange = (string)($dtRange ?? '');
|
||||||
|
|
||||||
// ⚠️⚠️ 临时变量**一律带 dt 前缀**,一个都不能漏:这里原来写成
|
// ⚠️⚠️ 临时变量**一律带 dt 前缀**,一个都不能漏:这里原来写成
|
||||||
// `$t = strtotime(...)`,把宿主模板里表示「这条任务 / 这条记录」的 `$t`
|
// `$t = strtotime(...)`,把宿主模板里表示「这条任务 / 这条记录」的 `$t`
|
||||||
@@ -42,9 +53,11 @@ $dtSec = $dtTs ? (int)date('s', $dtTs) : 0;
|
|||||||
· Semi 资源(3.4 MB JS)只有页面真出现 .dt-input 才由 app.js 懒加载;
|
· Semi 资源(3.4 MB JS)只有页面真出现 .dt-input 才由 app.js 懒加载;
|
||||||
· 加载成功 → 隐藏 .dt-native、在 .dt-semi 里挂 DatePicker,值回写原字段;
|
· 加载成功 → 隐藏 .dt-native、在 .dt-semi 里挂 DatePicker,值回写原字段;
|
||||||
· 加载失败/断网 → 原生控件原样可见可用,页面不会坏。 */
|
· 加载失败/断网 → 原生控件原样可见可用,页面不会坏。 */
|
||||||
$dtCfg = ['name' => $dtName, 'seconds' => $dtSeconds, 'required' => $dtRequired, 'id' => $dtId];
|
$dtCfg = ['name' => $dtName, 'seconds' => $dtSeconds, 'required' => $dtRequired,
|
||||||
|
'id' => $dtId, 'range' => $dtRange];
|
||||||
?>
|
?>
|
||||||
<div class="dt-input" data-dt="<?= e(json_encode($dtCfg, JSON_UNESCAPED_UNICODE | JSON_HEX_APOS | JSON_HEX_QUOT)) ?>">
|
<div class="dt-input<?= $dtRange !== '' ? ' dt-part' : '' ?>"
|
||||||
|
data-dt="<?= e(json_encode($dtCfg, JSON_UNESCAPED_UNICODE | JSON_HEX_APOS | JSON_HEX_QUOT)) ?>">
|
||||||
<span class="dt-semi" aria-hidden="true"></span>
|
<span class="dt-semi" aria-hidden="true"></span>
|
||||||
<span class="dt-native">
|
<span class="dt-native">
|
||||||
<input type="date" class="dt-date" name="<?= e($dtName) ?>_date"
|
<input type="date" class="dt-date" name="<?= e($dtName) ?>_date"
|
||||||
@@ -78,7 +91,7 @@ $dtCfg = ['name' => $dtName, 'seconds' => $dtSeconds, 'required' => $dtRequired,
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<?php /* 用完即清,避免残留变量影响下一次 include */ unset(
|
<?php /* 用完即清,避免残留变量影响下一次 include */ unset(
|
||||||
$dtName, $dtValue, $dtRequired, $dtId, $dtSeconds, $dtCfg,
|
$dtName, $dtValue, $dtRequired, $dtId, $dtSeconds, $dtCfg, $dtRange,
|
||||||
$dtTs, $dtTsTmp, $dtDate, $dtHour, $dtMin, $dtSec,
|
$dtTs, $dtTsTmp, $dtDate, $dtHour, $dtMin, $dtSec,
|
||||||
$dtH, $dtM, $dtS, $hv, $mv, $sv
|
$dtH, $dtM, $dtS, $hv, $mv, $sv
|
||||||
); ?>
|
); ?>
|
||||||
|
|||||||
@@ -50,10 +50,14 @@ $evIsAllDay = array_key_exists('all_day', $evF) ? !empty($evF['all_day']) : $evA
|
|||||||
|
|
||||||
<?php /* v24 第 5、6 条:起止各一个「日期 + 时分秒」合在一起的控件(全局统一),
|
<?php /* v24 第 5、6 条:起止各一个「日期 + 时分秒」合在一起的控件(全局统一),
|
||||||
不再拆成"日期一格 + 时刻一格"。两个控件长得一样,扫一眼就知道哪个是开始。 */ ?>
|
不再拆成"日期一格 + 时刻一格"。两个控件长得一样,扫一眼就知道哪个是开始。 */ ?>
|
||||||
|
<?php /* 用户 2026-10-04 第 7 条:日程的起止也是一个**范围** ——
|
||||||
|
升级后由一个 Semi dateTimeRange 一次选完。服务端仍收两组 *_date/_hour/… 字段。 */ ?>
|
||||||
|
<div class="dt-range">
|
||||||
<div class="field ev-dt" data-dt>
|
<div class="field ev-dt" data-dt>
|
||||||
<label>开始 *</label>
|
<label>开始 *</label>
|
||||||
<?php $dtName = 'starts_at'; $dtId = 'ev-start';
|
<?php $dtName = 'starts_at'; $dtId = 'ev-start';
|
||||||
$dtValue = $evF['starts_at'] ?? null; $dtRequired = true; $dtSeconds = true;
|
$dtValue = $evF['starts_at'] ?? null; $dtRequired = true; $dtSeconds = true;
|
||||||
|
$dtRange = 'start';
|
||||||
require __DIR__ . '/../_datetime.php'; ?>
|
require __DIR__ . '/../_datetime.php'; ?>
|
||||||
</div>
|
</div>
|
||||||
<?php /* 用户 2026-10-03 第 6 条:「添加结束日期与时间」——
|
<?php /* 用户 2026-10-03 第 6 条:「添加结束日期与时间」——
|
||||||
@@ -64,8 +68,10 @@ $evIsAllDay = array_key_exists('all_day', $evF) ? !empty($evF['all_day']) : $evA
|
|||||||
<label>结束</label>
|
<label>结束</label>
|
||||||
<?php $dtName = 'ends_at'; $dtId = 'ev-end';
|
<?php $dtName = 'ends_at'; $dtId = 'ev-end';
|
||||||
$dtValue = $evF['ends_at'] ?? null; $dtRequired = false; $dtSeconds = true;
|
$dtValue = $evF['ends_at'] ?? null; $dtRequired = false; $dtSeconds = true;
|
||||||
|
$dtRange = 'end';
|
||||||
require __DIR__ . '/../_datetime.php'; ?>
|
require __DIR__ . '/../_datetime.php'; ?>
|
||||||
</div>
|
</div>
|
||||||
|
</div><?php /* /dt-range */ ?>
|
||||||
<div class="field" id="ev-end-add">
|
<div class="field" id="ev-end-add">
|
||||||
<label>结束时间</label>
|
<label>结束时间</label>
|
||||||
<button type="button" class="btn btn-ghost btn-sm" id="ev-end-add-btn">+ 添加结束时间</button>
|
<button type="button" class="btn btn-ghost btn-sm" id="ev-end-add-btn">+ 添加结束时间</button>
|
||||||
@@ -176,6 +182,11 @@ $evIsAllDay = array_key_exists('all_day', $evF) ? !empty($evF['all_day']) : $evA
|
|||||||
function showEnd(on) {
|
function showEnd(on) {
|
||||||
if (endField) { endField.hidden = !on; }
|
if (endField) { endField.hidden = !on; }
|
||||||
if (endAdd) { endAdd.hidden = on; }
|
if (endAdd) { endAdd.hidden = on; }
|
||||||
|
// 结束时间刚被显示出来 → 现在两端都可见了,可以合并成一个范围控件(用户第 7 条)。
|
||||||
|
// 收起时不动已有的范围(它本来就只显示在第一端上,不影响使用)。
|
||||||
|
if (on && typeof window.fbUpgradeRange === 'function') {
|
||||||
|
window.fbUpgradeRange();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
var endTouched = false;
|
var endTouched = false;
|
||||||
endInputs().forEach(function (el) {
|
endInputs().forEach(function (el) {
|
||||||
|
|||||||
@@ -71,6 +71,27 @@ $headText = $view === 'month' ? date('Y 年 n 月', $ts)
|
|||||||
<?php if ($canWrite && $writable): ?>
|
<?php if ($canWrite && $writable): ?>
|
||||||
<button class="btn" type="button" id="calAddBtn">新建日程</button>
|
<button class="btn" type="button" id="calAddBtn">新建日程</button>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
<?php /* 用户 2026-10-04 第 6 条原话:「日程页面,在设置按钮的前面,加一个日历按钮,
|
||||||
|
选择日历后,跳转到那一天。」
|
||||||
|
→ 一个带日历图标的按钮,点开是日期选择;选中即跳到那天的**日视图**。
|
||||||
|
⚠️ 用原生 <input type="date"> 而不是再引一个弹层:它的原生日历足够用,
|
||||||
|
而且**选中就有值**(change 事件)→ 直接跳转,不用再点一次"确定"。
|
||||||
|
桌面/手机/无 JS 三种情况下的行为都是可预期的。 */ ?>
|
||||||
|
<form class="cal-jump" method="get" action="index.php">
|
||||||
|
<input type="hidden" name="r" value="calendar">
|
||||||
|
<input type="hidden" name="view" value="day">
|
||||||
|
<label class="btn btn-ghost cal-jump-btn" title="跳到某一天">
|
||||||
|
<svg viewBox="0 0 16 16" width="17" height="17" aria-hidden="true" fill="none"
|
||||||
|
stroke="currentColor" stroke-width="1.5" stroke-linecap="round">
|
||||||
|
<rect x="2" y="3.2" width="12" height="10.8" rx="1.6"/>
|
||||||
|
<path d="M2 6.6h12M5.2 1.8v2.6M10.8 1.8v2.6"/>
|
||||||
|
</svg>
|
||||||
|
<span class="cal-jump-txt">跳到</span>
|
||||||
|
<input class="cal-jump-date" type="date" name="d" value="<?= e($anchor) ?>"
|
||||||
|
aria-label="选择要跳转的日期">
|
||||||
|
</label>
|
||||||
|
<noscript><button class="btn btn-ghost btn-sm" type="submit">跳转</button></noscript>
|
||||||
|
</form>
|
||||||
<?php /* 用户第 4、5 条:日历本 / 颜色 / 导入 / 默认视图 全收进这个 ☰ ——
|
<?php /* 用户第 4、5 条:日历本 / 颜色 / 导入 / 默认视图 全收进这个 ☰ ——
|
||||||
它顶替了原来左边那一栏,所以排在**最右**(靠 .page-head .actions 的顺序)。 */ ?>
|
它顶替了原来左边那一栏,所以排在**最右**(靠 .page-head .actions 的顺序)。 */ ?>
|
||||||
<?php $setCurrent = 'view'; require __DIR__ . '/_settings_menu.php'; ?>
|
<?php $setCurrent = 'view'; require __DIR__ . '/_settings_menu.php'; ?>
|
||||||
@@ -247,6 +268,14 @@ $headText = $view === 'month' ? date('Y 年 n 月', $ts)
|
|||||||
var startSec = startField ? startField.querySelector('select.dt-second') : null;
|
var startSec = startField ? startField.querySelector('select.dt-second') : null;
|
||||||
|
|
||||||
function setStart(dateStr, hh, mm) {
|
function setStart(dateStr, hh, mm) {
|
||||||
|
// ⚠️ 优先走全局接口:它会把原生字段与 Semi 控件**一起**更新。
|
||||||
|
// 直接写 startDate.value 的话,Semi 是非受控组件、显示不会跟着变 ——
|
||||||
|
// 用户会看到"我点了 15:00,控件里还写着 09:00"(提交的却是 15:00)。
|
||||||
|
if (typeof window.fbSetDT === 'function'
|
||||||
|
&& window.fbSetDT('starts_at', dateStr + ' ' + (hh || '09') + ':' + (mm || '00') + ':00')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 兜底:Semi 还没加载完(或加载失败)→ 原生控件照旧能用
|
||||||
if (!startDate) return;
|
if (!startDate) return;
|
||||||
startDate.value = dateStr;
|
startDate.value = dateStr;
|
||||||
if (hh !== null && startHour) { startHour.value = hh; }
|
if (hh !== null && startHour) { startHour.value = hh; }
|
||||||
@@ -300,4 +329,26 @@ $headText = $view === 'month' ? date('Y 年 n 月', $ts)
|
|||||||
}
|
}
|
||||||
/* 「全天」勾上藏掉时刻那一段的逻辑在 _event_fields.php 里(两个页面共用一份) */
|
/* 「全天」勾上藏掉时刻那一段的逻辑在 _event_fields.php 里(两个页面共用一份) */
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
/* ---- 用户第 6 条:「跳到某一天」—— 选中即跳,不用再点确定 ---- */
|
||||||
|
(function () {
|
||||||
|
var box = document.querySelector('.cal-jump');
|
||||||
|
if (!box) { return; }
|
||||||
|
var inp = box.querySelector('.cal-jump-date');
|
||||||
|
if (!inp) { return; }
|
||||||
|
inp.addEventListener('change', function () {
|
||||||
|
if (inp.value) { box.submit(); }
|
||||||
|
});
|
||||||
|
// 点标签区域也应能弹出原生日历(有些浏览器只认输入框本身)
|
||||||
|
var lab = box.querySelector('.cal-jump-btn');
|
||||||
|
if (lab) {
|
||||||
|
lab.addEventListener('click', function (e) {
|
||||||
|
if (e.target === inp) { return; }
|
||||||
|
if (typeof inp.showPicker === 'function') {
|
||||||
|
try { inp.showPicker(); } catch (err) { /* 用户手势之外会被拒,忽略 */ }
|
||||||
|
}
|
||||||
|
inp.focus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
+153
-37
@@ -152,26 +152,52 @@ $ledMe = (int)($me ?? 0);
|
|||||||
</div>
|
</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
<?php /* ==================== ② 变化图(用户第 8 条)==================== */ ?>
|
<?php /* ==================== ② 变化图(v24 第 10 批 #3 重做)====================
|
||||||
<?php if ($charts || count($timeline) > 1): ?>
|
用户原话:「在账目上面,增加一个图表显示,**显示所有账目和总金额的变化曲线**,
|
||||||
|
根据盘点的金额进行显示。横轴就是时间,纵轴是金额,用折线图。」
|
||||||
|
⚠️ 与旧版的差别:① 总金额(净资产)**永远在最前**;② 每条账目都出线(不再只画盘点过的);
|
||||||
|
③ 加一条"按类别看"的切换条 —— 因为"所有账目"在几百条时会变成一坨,
|
||||||
|
把筛选交给用户,而不是我们偷偷少画几条。 */ ?>
|
||||||
|
<?php if ($entries): ?>
|
||||||
<div class="card card-pad led-charts">
|
<div class="card card-pad led-charts">
|
||||||
<h2 class="cinema-h2">
|
<h2 class="cinema-h2">
|
||||||
金额变化
|
金额变化
|
||||||
<span class="hint">每次「盘点」记一个点,旧数字不会被清掉</span>
|
<span class="hint">横轴时间 · 纵轴金额;每次「盘点」记一个点,旧数字不会被清掉</span>
|
||||||
</h2>
|
</h2>
|
||||||
<div class="ck-grid">
|
|
||||||
<?php if (count($timeline) > 1): ?>
|
<?php if ($timeline): ?>
|
||||||
<div class="ck-card">
|
<div class="ck-card ck-total">
|
||||||
<div class="ck-head">
|
<div class="ck-head">
|
||||||
<b>整本账 · 净资产</b>
|
<b>总金额 · 净资产</b>
|
||||||
<span class="small muted"><?= count($timeline) ?> 个时间点</span>
|
<span class="small muted"><?= count($timeline) ?> 个时间点</span>
|
||||||
</div>
|
</div>
|
||||||
<?= chart_line($timeline, '净资产', 'asset', 320) ?>
|
<?= chart_line($timeline, '净资产', 'asset', 900) ?>
|
||||||
</div>
|
</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if ($charts): ?>
|
||||||
|
<?php
|
||||||
|
// 类别筛选条:只列出**真有账目**的类别,点一下只看这一类。
|
||||||
|
$kindsPresent = [];
|
||||||
|
foreach ($charts as $ch) {
|
||||||
|
$k = (string)$ch['entry']['kind'];
|
||||||
|
$kindsPresent[$k] = ($kindsPresent[$k] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
<div class="ck-filter" data-ck-filter>
|
||||||
|
<button type="button" class="ck-chip is-on" data-ck-kind="">全部(<?= count($charts) ?>)</button>
|
||||||
|
<?php foreach ($kindsPresent as $k => $n): ?>
|
||||||
|
<button type="button" class="ck-chip<?= \App\Ledger::isDebt((string)$k) ? ' is-debt' : '' ?>"
|
||||||
|
data-ck-kind="<?= e((string)$k) ?>">
|
||||||
|
<?= \App\Ledger::kindIcon((string)$k) ?> <?= e(\App\Ledger::kindWord((string)$k)) ?>(<?= (int)$n ?>)
|
||||||
|
</button>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ck-grid">
|
||||||
<?php foreach ($charts as $ch): ?>
|
<?php foreach ($charts as $ch): ?>
|
||||||
<?php $en = $ch['entry']; $ek = (string)$en['kind']; ?>
|
<?php $en = $ch['entry']; $ek = (string)$en['kind']; ?>
|
||||||
<div class="ck-card">
|
<div class="ck-card" data-ck-card="<?= e($ek) ?>">
|
||||||
<div class="ck-head">
|
<div class="ck-head">
|
||||||
<b><?= e((string)$en['title']) ?></b>
|
<b><?= e((string)$en['title']) ?></b>
|
||||||
<span class="small muted">
|
<span class="small muted">
|
||||||
@@ -183,9 +209,9 @@ $ledMe = (int)($me ?? 0);
|
|||||||
</div>
|
</div>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</div>
|
</div>
|
||||||
<?php if (!$charts): ?>
|
|
||||||
<p class="small muted" style="margin:8px 2px 0">
|
<p class="small muted" style="margin:8px 2px 0">
|
||||||
还没有任何盘点记录 —— 下面账目表里每行都有「盘点」,盘过之后这里就会出现它自己的曲线。
|
没有盘点记录的账目只会显示一个点(它一直没变过)——
|
||||||
|
在下面账目表里点「盘点」记下当前金额,这条线就长出来了。
|
||||||
</p>
|
</p>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
@@ -225,32 +251,41 @@ $ledMe = (int)($me ?? 0);
|
|||||||
<?php else: ?><span class="small muted">—</span><?php endif; ?>
|
<?php else: ?><span class="small muted">—</span><?php endif; ?>
|
||||||
</td>
|
</td>
|
||||||
<td class="small muted"><?= e((string)($en['note'] ?? '')) ?></td>
|
<td class="small muted"><?= e((string)($en['note'] ?? '')) ?></td>
|
||||||
<td class="nowrap">
|
<td class="nowrap led-acts">
|
||||||
<?php if ($canWrite): ?>
|
<?php /* 用户 2026-10-04 第 3 条原话:「账目列表,右侧两个按钮,
|
||||||
<details class="led-edit">
|
一个**盘点**,点击后,输入最新的金额;一个按钮**修改**,
|
||||||
<summary class="link-quiet">操作</summary>
|
用来修改名称、挂在谁名下、备注等。」
|
||||||
|
→ 所以这两个是**并列的按钮**,各自弹自己的面板;
|
||||||
|
不再是一个「操作」里塞两坨表单(那要展开两层才够得着)。 */ ?>
|
||||||
|
<?php if ($canWrite): $histN = count(\App\Ledger::history($eid)); ?>
|
||||||
|
<button type="button" class="btn btn-ghost btn-sm led-act"
|
||||||
|
data-led-open="recon-<?= $eid ?>">盘点</button>
|
||||||
|
<button type="button" class="btn btn-ghost btn-sm led-act"
|
||||||
|
data-led-open="edit-<?= $eid ?>">修改</button>
|
||||||
|
|
||||||
<?php /* ---- 盘点(用户第 8 条):重新输一个金额,旧值进历史 ---- */ ?>
|
<div class="led-panel" id="recon-<?= $eid ?>" hidden>
|
||||||
<form method="post" action="<?= e(url('ledger/reconcile')) ?>" class="node-form led-recon">
|
<form method="post" action="<?= e(url('ledger/reconcile')) ?>" class="led-recon">
|
||||||
<?= csrf_field() ?>
|
<?= csrf_field() ?>
|
||||||
<input type="hidden" name="ledger_id" value="<?= $curId ?>">
|
<input type="hidden" name="ledger_id" value="<?= $curId ?>">
|
||||||
<input type="hidden" name="id" value="<?= $eid ?>">
|
<input type="hidden" name="id" value="<?= $eid ?>">
|
||||||
<h4 class="led-form-h">盘点这笔(旧金额会记进历史)</h4>
|
<h4 class="led-form-h">盘点「<?= e((string)$en['title']) ?>」</h4>
|
||||||
<div class="nf-grid">
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>盘点后的金额(元)</label>
|
<label>最新金额(元)</label>
|
||||||
<input class="input" name="amount" inputmode="decimal" required
|
<input class="input" name="amount" inputmode="decimal" required
|
||||||
value="<?= e(\App\Ledger::amountYuan((int)$en['amount_cents'])) ?>">
|
value="<?= e(\App\Ledger::amountYuan((int)$en['amount_cents'])) ?>">
|
||||||
|
<span class="tip">盘点前的金额会记进历史,图表上就多一个点。</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>盘点备注</label>
|
<label>盘点备注</label>
|
||||||
<input class="input" name="note" placeholder="如:2026 年初盘点">
|
<input class="input" name="note" placeholder="如:2026 年初盘点">
|
||||||
</div>
|
</div>
|
||||||
|
<div class="btn-row">
|
||||||
|
<button class="btn btn-sm" type="submit">记下这次盘点</button>
|
||||||
|
<button class="btn btn-ghost btn-sm" type="button" data-led-close>取消</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="btn-row"><button class="btn btn-sm" type="submit">盘点</button></div>
|
<?php if ($histN): ?>
|
||||||
<?php if (\App\Ledger::history($eid)): ?>
|
|
||||||
<details class="led-hist">
|
<details class="led-hist">
|
||||||
<summary class="link-quiet">看历史(<?= count(\App\Ledger::history($eid)) ?> 次)</summary>
|
<summary class="link-quiet">看历史(<?= $histN ?> 次)</summary>
|
||||||
<ul class="led-hist-list">
|
<ul class="led-hist-list">
|
||||||
<?php foreach (\App\Ledger::history($eid) as $hs): ?>
|
<?php foreach (\App\Ledger::history($eid) as $hs): ?>
|
||||||
<li>
|
<li>
|
||||||
@@ -265,28 +300,50 @@ $ledMe = (int)($me ?? 0);
|
|||||||
</details>
|
</details>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</form>
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
<?php /* ---- 改 / 删(改 = 修正录错了,与"盘点"是两件事,见 Ledger::reconcile)---- */ ?>
|
<div class="led-panel" id="edit-<?= $eid ?>" hidden>
|
||||||
<form method="post" action="<?= e(url('ledger/entry_update')) ?>" class="node-form">
|
<?php /* 用户第 3 条:修改的作用域是**名称 / 挂在谁名下 / 备注等** ——
|
||||||
|
与"更正金额"分开:改金额属于"当初录错了",那走盘点(留历史);
|
||||||
|
这里改的是描述性字段,所以**也可以含类别与金额**(都是描述),
|
||||||
|
但文案上讲清楚它与盘点的区别。 */ ?>
|
||||||
|
<form method="post" action="<?= e(url('ledger/entry_update')) ?>">
|
||||||
<?= csrf_field() ?>
|
<?= csrf_field() ?>
|
||||||
<input type="hidden" name="ledger_id" value="<?= $curId ?>">
|
<input type="hidden" name="ledger_id" value="<?= $curId ?>">
|
||||||
<input type="hidden" name="id" value="<?= $eid ?>">
|
<input type="hidden" name="id" value="<?= $eid ?>">
|
||||||
<h4 class="led-form-h">更正这笔(当作当初就录错了,不留历史)</h4>
|
<h4 class="led-form-h">修改「<?= e((string)$en['title']) ?>」</h4>
|
||||||
<div class="nf-grid">
|
<p class="tip" style="margin:0 0 8px">
|
||||||
|
改的是名称、归属与备注。若只是**金额变了**(不是录错),
|
||||||
|
请用「盘点」——那样才会记进历史、在图上多一个点。
|
||||||
|
</p>
|
||||||
|
<div class="field">
|
||||||
|
<label>名称</label>
|
||||||
|
<input class="input" name="title" value="<?= e((string)$en['title']) ?>" required>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>挂在谁名下</label>
|
||||||
|
<?php
|
||||||
|
$ppName = 'person_id';
|
||||||
|
$ppPeople = $people;
|
||||||
|
$ppSelected = !empty($en['person_id']) ? [(int)$en['person_id']] : [];
|
||||||
|
$ppMode = 'single';
|
||||||
|
$ppOpenBtn = '更换归属人';
|
||||||
|
$ppTitle = '这笔账挂在谁名下';
|
||||||
|
$ppEmptyText= '不指定';
|
||||||
|
$ppAllowEmpty = true;
|
||||||
|
require __DIR__ . '/../_person_picker.php';
|
||||||
|
?>
|
||||||
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>类别</label>
|
<label>类别</label>
|
||||||
<select class="select" name="kind">
|
<select class="select" name="kind">
|
||||||
<?php foreach (\App\Ledger::KINDS as $k => $v): ?>
|
<?php foreach (\App\Ledger::KINDS as $k => $v): ?>
|
||||||
<option value="<?= e($k) ?>" <?= $ek === $k ? 'selected' : '' ?>>
|
<option value="<?= e($k) ?>" <?= $ek === $k ? 'selected' : '' ?>>
|
||||||
<?= e($v[0]) ?>
|
<?= e($v[2] . ' ' . $v[0]) ?><?= $v[1] === 'debt' ? '(负债)' : '' ?>
|
||||||
</option>
|
</option>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
|
||||||
<label>名称</label>
|
|
||||||
<input class="input" name="title" value="<?= e((string)$en['title']) ?>" required>
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>金额(元)</label>
|
<label>金额(元)</label>
|
||||||
<input class="input" name="amount"
|
<input class="input" name="amount"
|
||||||
@@ -296,17 +353,20 @@ $ledMe = (int)($me ?? 0);
|
|||||||
<label>备注</label>
|
<label>备注</label>
|
||||||
<input class="input" name="note" value="<?= e((string)($en['note'] ?? '')) ?>">
|
<input class="input" name="note" value="<?= e((string)($en['note'] ?? '')) ?>">
|
||||||
</div>
|
</div>
|
||||||
|
<div class="btn-row">
|
||||||
|
<button class="btn btn-sm" type="submit">保存</button>
|
||||||
|
<button class="btn btn-ghost btn-sm" type="button" data-led-close>取消</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="btn-row"><button class="btn btn-sm" type="submit">保存更正</button></div>
|
|
||||||
</form>
|
</form>
|
||||||
<form method="post" action="<?= e(url('ledger/entry_del')) ?>" class="res-inline">
|
<form method="post" action="<?= e(url('ledger/entry_del')) ?>" class="res-inline"
|
||||||
|
style="margin-top:8px">
|
||||||
<?= csrf_field() ?>
|
<?= csrf_field() ?>
|
||||||
<input type="hidden" name="ledger_id" value="<?= $curId ?>">
|
<input type="hidden" name="ledger_id" value="<?= $curId ?>">
|
||||||
<input type="hidden" name="id" value="<?= $eid ?>">
|
<input type="hidden" name="id" value="<?= $eid ?>">
|
||||||
<button class="btn btn-ghost btn-sm" type="submit"
|
<button class="btn btn-ghost btn-sm" type="submit"
|
||||||
onclick="return confirm('删掉这笔账目?')">删除</button>
|
onclick="return confirm('删掉这笔账目?')">删除这笔</button>
|
||||||
</form>
|
</form>
|
||||||
</details>
|
</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -326,13 +386,69 @@ $ledMe = (int)($me ?? 0);
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
|
/* ---- 「记一笔」开合 ---- */
|
||||||
var btn = document.getElementById('ledAddBtn');
|
var btn = document.getElementById('ledAddBtn');
|
||||||
var card = document.getElementById('ledAddCard');
|
var card = document.getElementById('ledAddCard');
|
||||||
if (!btn || !card) return;
|
if (btn && card) {
|
||||||
btn.addEventListener('click', function () {
|
btn.addEventListener('click', function () {
|
||||||
card.hidden = !card.hidden;
|
card.hidden = !card.hidden;
|
||||||
btn.textContent = card.hidden ? '记一笔' : '收起';
|
btn.textContent = card.hidden ? '记一笔' : '收起';
|
||||||
if (!card.hidden) { card.scrollIntoView({ block: 'nearest' }); }
|
if (!card.hidden) { card.scrollIntoView({ block: 'nearest' }); }
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- 每行「盘点 / 修改」两个按钮:弹各自的面板(v24 第 10 批 #3)----
|
||||||
|
面板贴着那一行展开(不是弹窗遮罩),改完就在原地看得到结果。
|
||||||
|
同时只开一个;点别处 / Esc 收起。 */
|
||||||
|
var openPanel = null;
|
||||||
|
function closePanel() {
|
||||||
|
if (openPanel) { openPanel.hidden = true; openPanel = null; }
|
||||||
|
}
|
||||||
|
document.addEventListener('click', function (e) {
|
||||||
|
var t = e.target;
|
||||||
|
if (!t || !t.closest) { return; }
|
||||||
|
if (t.closest('[data-led-close]')) { closePanel(); return; }
|
||||||
|
var trigger = t.closest('[data-led-open]');
|
||||||
|
if (trigger) {
|
||||||
|
var id = trigger.getAttribute('data-led-open');
|
||||||
|
var panel = document.getElementById(id);
|
||||||
|
if (!panel) { return; }
|
||||||
|
var wasOpen = (panel === openPanel);
|
||||||
|
closePanel();
|
||||||
|
if (wasOpen) { return; } // 再点一次 = 收起
|
||||||
|
panel.hidden = false;
|
||||||
|
openPanel = panel;
|
||||||
|
// 把面板插到这一行**后面**(作为整行的兄弟),而不是塞在 <td> 里 ——
|
||||||
|
// 表格单元格会被列宽切碎,表单在格子里根本排不开。
|
||||||
|
var tr = trigger.closest('tr');
|
||||||
|
if (tr && panel.parentNode !== tr.parentNode) {
|
||||||
|
tr.parentNode.insertBefore(panel, tr.nextSibling);
|
||||||
|
}
|
||||||
|
// 焦点给到第一个输入框:点完就能打字
|
||||||
|
var inp = panel.querySelector('input:not([type=hidden]), select');
|
||||||
|
if (inp) { inp.focus(); }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (openPanel && !openPanel.contains(t)) { closePanel(); }
|
||||||
|
}, true);
|
||||||
|
document.addEventListener('keydown', function (e) {
|
||||||
|
if (e.key === 'Escape') { closePanel(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ---- 图表按类别筛选("所有账目"在几百条时会变成一坨)---- */
|
||||||
|
var bar = document.querySelector('[data-ck-filter]');
|
||||||
|
if (bar) {
|
||||||
|
bar.addEventListener('click', function (e) {
|
||||||
|
var chip = e.target.closest('.ck-chip');
|
||||||
|
if (!chip) { return; }
|
||||||
|
var kind = chip.getAttribute('data-ck-kind') || '';
|
||||||
|
Array.prototype.forEach.call(bar.querySelectorAll('.ck-chip'), function (c) {
|
||||||
|
c.classList.toggle('is-on', c === chip);
|
||||||
|
});
|
||||||
|
Array.prototype.forEach.call(document.querySelectorAll('[data-ck-card]'), function (c) {
|
||||||
|
c.hidden = kind !== '' && c.getAttribute('data-ck-card') !== kind;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
@@ -73,18 +73,21 @@ $selAttendees = array_map('intval', (array)old('attendees', $curAttendees));
|
|||||||
<?php if (!empty($errors['title'])): ?><span class="err"><?= e($errors['title']) ?></span><?php endif; ?>
|
<?php if (!empty($errors['title'])): ?><span class="err"><?= e($errors['title']) ?></span><?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field full">
|
<?php /* 用户 2026-10-04 第 7 条:起止是一个**范围**,应该由日期插件的
|
||||||
<label <?= $isEdit ? '' : 'for="starts_at_date"' ?>>开始时间(精确到秒)</label>
|
范围选择方式一次选完,而不是并列两个控件(不好看、也多一步操作)。
|
||||||
|
服务端仍然收 starts_at_* / ends_at_* 两组字段(契约不变),
|
||||||
|
只是升级后由**一个** Semi dateTimeRange 统一填两端(见 app.js)。 */ ?>
|
||||||
|
<div class="field full dt-range">
|
||||||
|
<label <?= $isEdit ? '' : 'for="starts_at_date"' ?>>开始 / 结束时间(精确到秒)</label>
|
||||||
<?php $dtName = 'starts_at'; $dtValue = $dtStart; $dtId = 'starts_at_date';
|
<?php $dtName = 'starts_at'; $dtValue = $dtStart; $dtId = 'starts_at_date';
|
||||||
|
$dtRange = 'start';
|
||||||
require __DIR__ . '/../_datetime.php'; ?>
|
require __DIR__ . '/../_datetime.php'; ?>
|
||||||
<?php if (!empty($errors['starts_at'])): ?><span class="err"><?= e($errors['starts_at']) ?></span><?php endif; ?>
|
<?php if (!empty($errors['starts_at'])): ?><span class="err"><?= e($errors['starts_at']) ?></span><?php endif; ?>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field full">
|
|
||||||
<label>结束时间(散场,可不填)</label>
|
|
||||||
<?php $dtName = 'ends_at'; $dtValue = $dtEnd; $dtId = '';
|
<?php $dtName = 'ends_at'; $dtValue = $dtEnd; $dtId = '';
|
||||||
|
$dtRange = 'end';
|
||||||
require __DIR__ . '/../_datetime.php'; ?>
|
require __DIR__ . '/../_datetime.php'; ?>
|
||||||
<?php if (!empty($errors['ends_at'])): ?><span class="err"><?= e($errors['ends_at']) ?></span><?php endif; ?>
|
<?php if (!empty($errors['ends_at'])): ?><span class="err"><?= e($errors['ends_at']) ?></span><?php endif; ?>
|
||||||
|
<span class="tip">散场时间可不填。</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field full">
|
<div class="field full">
|
||||||
|
|||||||
+173
-43
@@ -27,10 +27,31 @@ foreach ($grouped as $list) {
|
|||||||
* 画一栏人。用闭包是因为"分好组的各栏"和"未分类"两处要画同一套卡片,
|
* 画一栏人。用闭包是因为"分好组的各栏"和"未分类"两处要画同一套卡片,
|
||||||
* 复制一遍迟早会改漏一处。
|
* 复制一遍迟早会改漏一处。
|
||||||
*
|
*
|
||||||
* ⚠️ `$groups` 必须写进 `use (...)`:卡片里的「调整分组」下拉要列出我能用的分组。
|
* ⚠️ `$groups` 必须写进 `use (...)`:卡片里的「分组」按钮要用它(见下)。
|
||||||
* 漏掉它的后果**不会报错**(PHP 8 只 warning 一句"未定义变量",
|
* (历史:这个下拉原来长在卡片里,漏掉它会让下拉只剩「未分类」一项,
|
||||||
* 而 foreach 拿到 null 就是零次循环)—— 于是每张卡片的那个下拉里
|
* 看着像"分组功能没做",其实是变量没传进来。)
|
||||||
* 只剩「未分类」一项,看着像"分组功能没做",其实是变量没传进来。
|
*
|
||||||
|
* 🔴🔴 v24 第 10 批 #6:**卡片里不再内联弹层与表单**。
|
||||||
|
*
|
||||||
|
* 用户报「名册列表页面仍然很慢,Firefox 提示这个页面和 Bitwarden 插件有冲突」。
|
||||||
|
* 线上真实数据实测(1184 人)把这件事量出来是什么样:
|
||||||
|
* · 全页 `<form>` **2369 个**、`<input>` **27234 个**、`radio` **22496 个**;
|
||||||
|
* · 单张卡片 83 个 DOM 节点,其中**分组菜单独占 64 个**;
|
||||||
|
* · 于是总 DOM 98100 个、解压后 7.7 MB。
|
||||||
|
* 密码管理器(Bitwarden/1Password/LastPass…)会**逐个 form/input 去判定是不是登录框**,
|
||||||
|
* 2369 个表单足够把主线程占住 —— 这就是"和 Bitwarden 冲突"的真实来源,
|
||||||
|
* 也是"明明没报错却卡得没法用"的原因。
|
||||||
|
*
|
||||||
|
* 修法(不是调 CSS 缓存,而是**把节点数砍掉一个数量级**):
|
||||||
|
* ① 每卡的「分组」按钮只留一个 **<button data-pg-open="…">**(1 个节点,无表单无 radio);
|
||||||
|
* ② 每卡的「⋯」只留一个 **<button data-pm-open="…">**;
|
||||||
|
* ③ 「分组」「移出名册」需要的 **radio / 表单 / csrf** 全页只渲染**一份**,
|
||||||
|
* 放在列表末尾的共享浮层里(见文件末尾 `#rosterMenus`),点哪个人的按钮就把
|
||||||
|
* 该按钮的数据灌进去 —— 语义没变,服务端路由也没变(还是 people/group 与 people/forget)。
|
||||||
|
*
|
||||||
|
* 代价:没有 JS 时这两个按钮点不动。但**改分组/移出名册本来就只有 JS 可用场景**
|
||||||
|
* (拖动排序、即时筛选都不是无 JS 能用的),而且卡片上的「编辑」仍是普通链接,
|
||||||
|
* 全站核心功能不依赖它。页面正文明说这一点,不静默降级。
|
||||||
*/
|
*/
|
||||||
$renderGrid = function (array $list) use ($meId, $canMove, $groups): void {
|
$renderGrid = function (array $list) use ($meId, $canMove, $groups): void {
|
||||||
?>
|
?>
|
||||||
@@ -150,51 +171,24 @@ $renderGrid = function (array $list) use ($meId, $canMove, $groups): void {
|
|||||||
容器贴右、内部自然排开,.person-main 也就不用再靠 padding 让位。 */ ?>
|
容器贴右、内部自然排开,.person-main 也就不用再靠 padding 让位。 */ ?>
|
||||||
<div class="person-actions">
|
<div class="person-actions">
|
||||||
<?php if ($canMove): ?>
|
<?php if ($canMove): ?>
|
||||||
<?php /* 用户 2026-10-04 第 1 条:名册没有地方改分组了 → 加一个按钮,
|
<?php /* 用户 2026-10-04 第 1 条:名册要有地方改分组 → 「分组」按钮,
|
||||||
**直接显示当前分组**,点开就是分组列表,选中即改。
|
**直接显示当前分组**,点开就是分组列表,选中即改。
|
||||||
⚠️ 用 <details> 而不是新写一套弹窗 JS:没有 JS 时它照样能开、
|
v24 第 10 批 #6:按钮本身不再内联 <details>+<form>+11 个 radio,
|
||||||
照样能提交(里面的「保存」按钮),开了 JS 只是"选中即提交"。
|
只带数据、交给全页共享的那一份浮层(理由见 $renderGrid 的注释)。 */ ?>
|
||||||
⚠️ 值是原生 radio,**提交的是 form** —— 与名册页原来的
|
<button type="button" class="btn btn-ghost btn-sm person-group-btn<?= $grpName === '' ? ' is-none' : '' ?>"
|
||||||
「移动」是同一个路由(people/group),服务端不用改。 */ ?>
|
data-pg-open="<?= $pid ?>"
|
||||||
<details class="person-group menu" data-no-drag>
|
data-pg-name="<?= e($pshow) ?>"
|
||||||
<summary class="btn btn-ghost btn-sm person-group-btn<?= $grpName === '' ? ' is-none' : '' ?>"
|
data-pg-cur="<?= $curGid ?>"
|
||||||
title="改 <?= e($pshow) ?> 的分组"><?= e($grpName !== '' ? $grpName : '分组') ?></summary>
|
title="改 <?= e($pshow) ?> 的分组"><?= e($grpName !== '' ? $grpName : '分组') ?></button>
|
||||||
<form method="post" action="<?= e(url('people/group')) ?>" class="pg-menu">
|
|
||||||
<?= csrf_field() ?>
|
|
||||||
<input type="hidden" name="person_id" value="<?= $pid ?>">
|
|
||||||
<p class="pg-title">把「<?= e($pshow) ?>」分到</p>
|
|
||||||
<label class="pg-item">
|
|
||||||
<input type="radio" name="group_id" value=""<?= $curGid === 0 ? ' checked' : '' ?>>
|
|
||||||
<span>未分类</span>
|
|
||||||
</label>
|
|
||||||
<?php foreach ($groups as $g): $gid = (int)$g['id']; ?>
|
|
||||||
<label class="pg-item">
|
|
||||||
<input type="radio" name="group_id" value="<?= $gid ?>"<?= $curGid === $gid ? ' checked' : '' ?>>
|
|
||||||
<span><?= e((string)$g['name']) ?></span>
|
|
||||||
</label>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
<button class="btn btn-sm pg-save" type="submit">保存</button>
|
|
||||||
</form>
|
|
||||||
</details>
|
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<a class="btn btn-ghost btn-sm person-edit" data-no-drag
|
<a class="btn btn-ghost btn-sm person-edit" data-no-drag
|
||||||
href="<?= e(url('people/edit', ['id' => $pid])) ?>"
|
href="<?= e(url('people/edit', ['id' => $pid])) ?>"
|
||||||
title="编辑 <?= e($pshow) ?> 的资料">编辑</a>
|
title="编辑 <?= e($pshow) ?> 的资料">编辑</a>
|
||||||
<?php if ($canMove): ?>
|
<?php if ($canMove): ?>
|
||||||
<details class="person-more menu" data-no-drag>
|
<button type="button" class="btn btn-ghost btn-sm person-more-btn"
|
||||||
<summary aria-label="更多操作">⋯</summary>
|
data-pm-open="<?= $pid ?>"
|
||||||
<?php /* 「调整分组」那个下拉已经搬到上面的「分组」按钮里 —— 同一件事
|
data-pm-hint="<?= ($p['roster_source'] ?? '') === 'kin' ? '1' : '' ?>"
|
||||||
不做两处(留着反而会让人以为改了这边、那边没跟上)。 */ ?>
|
aria-label="更多操作" title="更多操作">⋯</button>
|
||||||
<?php if (($p['roster_source'] ?? '') === 'kin'): ?>
|
|
||||||
<p class="small muted person-move" style="margin:0">因家谱关系自动加入;解除关系后自动移出。</p>
|
|
||||||
<?php else: ?>
|
|
||||||
<form method="post" action="<?= e(url('people/forget')) ?>" class="person-move">
|
|
||||||
<?= csrf_field() ?>
|
|
||||||
<input type="hidden" name="person_id" value="<?= $pid ?>">
|
|
||||||
<button class="btn btn-ghost btn-sm" type="submit" data-no-drag>从我的名册移走</button>
|
|
||||||
</form>
|
|
||||||
<?php endif; ?>
|
|
||||||
</details>
|
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -313,6 +307,47 @@ $renderBlock = function (int $gid, string $name, array $list) use ($renderGrid):
|
|||||||
</p>
|
</p>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<?php /* ==================== 卡片动作的**共享浮层**(v24 第 10 批 #6)====================
|
||||||
|
这里只有**一份**「分组」表单和**一份**「移出名册」表单,全页共用。
|
||||||
|
每一行的按钮只带数据(data-pg-open / data-pm-open),点开时由 JS 把数据灌进来。
|
||||||
|
目的:把 2369 个 form / 27234 个 input / 22496 个 radio 压回个位数 ——
|
||||||
|
这正是 Bitwarden 那类密码管理器把页面拖卡的根源(它们要逐个 form 扫描)。
|
||||||
|
|
||||||
|
⚠️ 表单里留了原生「保存」按钮:即使 JS 出错,用户手动点它也能提交,
|
||||||
|
不会掉进"按钮点了没反应"的黑洞。 */ ?>
|
||||||
|
<?php if ($canMove): ?>
|
||||||
|
<div class="roster-menus" id="rosterMenus" hidden>
|
||||||
|
<div class="rm-panel" id="rmGroup" hidden>
|
||||||
|
<form method="post" action="<?= e(url('people/group')) ?>" class="pg-menu">
|
||||||
|
<?= csrf_field() ?>
|
||||||
|
<input type="hidden" name="person_id" value="">
|
||||||
|
<p class="pg-title">把「<b class="pg-who"></b>」分到</p>
|
||||||
|
<label class="pg-item">
|
||||||
|
<input type="radio" name="group_id" value="">
|
||||||
|
<span>未分类</span>
|
||||||
|
</label>
|
||||||
|
<?php foreach ($groups as $g): ?>
|
||||||
|
<label class="pg-item">
|
||||||
|
<input type="radio" name="group_id" value="<?= (int)$g['id'] ?>">
|
||||||
|
<span><?= e((string)$g['name']) ?></span>
|
||||||
|
</label>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<button class="btn btn-sm pg-save" type="submit">保存</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="rm-panel" id="rmMore" hidden>
|
||||||
|
<p class="small muted person-move is-kin" hidden style="margin:0 0 6px">
|
||||||
|
因家谱关系自动加入;解除关系后自动移出。
|
||||||
|
</p>
|
||||||
|
<form method="post" action="<?= e(url('people/forget')) ?>" class="person-move is-self">
|
||||||
|
<?= csrf_field() ?>
|
||||||
|
<input type="hidden" name="person_id" value="">
|
||||||
|
<button class="btn btn-ghost btn-sm" type="submit">从我的名册移走</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
<?php if ($q !== '' || $canMove): ?>
|
<?php if ($q !== '' || $canMove): ?>
|
||||||
@@ -335,5 +370,100 @@ $renderBlock = function (int $gid, string $name, array $list) use ($renderGrid):
|
|||||||
});
|
});
|
||||||
syncClear();
|
syncClear();
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
<?php /* ==================== 共享浮层的交互(v24 第 10 批 #6)====================
|
||||||
|
一行只留一个按钮,真正的表单在 #rosterMenus 里(全页一份)。
|
||||||
|
这里负责:点按钮 → 把该行的数据填进共享表单 → 浮层贴着那个按钮弹出来。 */ ?>
|
||||||
|
(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(); }
|
||||||
|
});
|
||||||
|
})();
|
||||||
</script>
|
</script>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|||||||
@@ -54,15 +54,16 @@ $nfPrefix = $nfIsEdit ? 'ne' . (int)$nfNode['id'] : ('n' . ($nfCategory === 'rea
|
|||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<?php /* 用户 2026-10-04 第 7 条:节点的起止也是**一个范围**,一次选完 */ ?>
|
||||||
<label>开始</label>
|
<div class="field dt-range">
|
||||||
|
<label>开始 / 结束</label>
|
||||||
<?php $dtName = 'starts_at'; $dtValue = $nfNode['starts_at'] ?? '';
|
<?php $dtName = 'starts_at'; $dtValue = $nfNode['starts_at'] ?? '';
|
||||||
$dtId = $nfPrefix . '-s'; $dtSeconds = true; require __DIR__ . '/../_datetime.php'; ?>
|
$dtId = $nfPrefix . '-s'; $dtSeconds = true; $dtRange = 'start';
|
||||||
</div>
|
require __DIR__ . '/../_datetime.php'; ?>
|
||||||
<div class="field">
|
|
||||||
<label>结束</label>
|
|
||||||
<?php $dtName = 'ends_at'; $dtValue = $nfNode['ends_at'] ?? '';
|
<?php $dtName = 'ends_at'; $dtValue = $nfNode['ends_at'] ?? '';
|
||||||
$dtId = $nfPrefix . '-e'; $dtSeconds = true; require __DIR__ . '/../_datetime.php'; ?>
|
$dtId = $nfPrefix . '-e'; $dtSeconds = true; $dtRange = 'end';
|
||||||
|
require __DIR__ . '/../_datetime.php'; ?>
|
||||||
|
<span class="tip">结束时间可不填。</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="field full">
|
<div class="field full">
|
||||||
<label for="<?= e($nfPrefix) ?>-detail">记录</label>
|
<label for="<?= e($nfPrefix) ?>-detail">记录</label>
|
||||||
|
|||||||
@@ -44,18 +44,19 @@ if ($isEdit) {
|
|||||||
<?php if (!empty($errors['title'])): ?><span class="err"><?= e($errors['title']) ?></span><?php endif; ?>
|
<?php if (!empty($errors['title'])): ?><span class="err"><?= e($errors['title']) ?></span><?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<?php /* 用户 2026-10-04 第 7 条:出发/回来是一个**时间范围**,
|
||||||
<label for="t-start">出发</label>
|
用一个范围选择器一次选完(不再是并列两个控件)。 */ ?>
|
||||||
|
<div class="field dt-range">
|
||||||
|
<label for="t-start">出发 / 回来</label>
|
||||||
<?php $dtName = 'starts_at'; $dtValue = $t['starts_at'] ?? '';
|
<?php $dtName = 'starts_at'; $dtValue = $t['starts_at'] ?? '';
|
||||||
$dtId = 't-start'; $dtSeconds = true; require __DIR__ . '/../_datetime.php'; ?>
|
$dtId = 't-start'; $dtSeconds = true; $dtRange = 'start';
|
||||||
|
require __DIR__ . '/../_datetime.php'; ?>
|
||||||
<?php if (!empty($errors['starts_at'])): ?><span class="err"><?= e($errors['starts_at']) ?></span><?php endif; ?>
|
<?php if (!empty($errors['starts_at'])): ?><span class="err"><?= e($errors['starts_at']) ?></span><?php endif; ?>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field">
|
|
||||||
<label for="t-end">回来</label>
|
|
||||||
<?php $dtName = 'ends_at'; $dtValue = $t['ends_at'] ?? ''; $dtId = 't-end'; $dtSeconds = true;
|
<?php $dtName = 'ends_at'; $dtValue = $t['ends_at'] ?? ''; $dtId = 't-end'; $dtSeconds = true;
|
||||||
|
$dtRange = 'end';
|
||||||
require __DIR__ . '/../_datetime.php'; ?>
|
require __DIR__ . '/../_datetime.php'; ?>
|
||||||
<?php if (!empty($errors['ends_at'])): ?><span class="err"><?= e($errors['ends_at']) ?></span><?php endif; ?>
|
<?php if (!empty($errors['ends_at'])): ?><span class="err"><?= e($errors['ends_at']) ?></span><?php endif; ?>
|
||||||
|
<span class="tip">回来的时间可不填。</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field full">
|
<div class="field full">
|
||||||
|
|||||||
Reference in New Issue
Block a user