v24 第 2 轮:19 条清单收尾(附件预览/CalDAV/任务分类看板/全局选人/家谱/纪念日/财务)
用户 2026-10-02 提的 19 条需求,本轮完成剩下的 8 条 + CalDAV 服务端:
- ③ 附件:图片/视频给缩略图,PDF/Word/Excel 给**同尺寸**图标;可预览的在本页
弹层里看(不再新开标签页),渲染不了的点了直接下载;预览层右上角恒有下载按钮。
- ⑦b CalDAV:把「我的日历本」按 RFC 4791 只读子集分享出去(OPTIONS/PROPFIND/
REPORT/GET,写方法一律 403)。用**专用口令**(库里只存 sha256),不是登录口令。
- ⑨⑩ 任务:分类与看板都由用户自建;已完成的任务从看板消失,改在「已完成」页
按分类查看/搜索;设置入口在「新建任务」旁的 ☰。
- ⑪ 全局人员选择器:一个片段铺到观影/旅程/资源/日历共享/任务共享/财务共享;
修掉了老组件「名字和拼音都搜不动」的 bug —— .att-item 自己声明了 display:flex,
压过了浏览器 [hidden]{display:none},过滤根本没生效。
- ⑫ 家谱:头像改方形不再裁切;姻亲不在图上的卡片给「⇄ 姻亲」入口,
以 TA 为中心重画 + 「返回我的家谱」。
- ⑬ 纪念日:阴历/阳历都能设且按对应历法重复;名册里每个人的生日默认可见
(实时派生,不复制一份);全部作为全天日程进日历。
- ⑮ 财务:账本可共享,双方都能记账、都能看全部账目,资产−负债=净资产;
金额一律以「分」存,不用浮点。
验证:端到端 724 项通过 / 0 失败;几何 368、交互 97、移动端 516、家谱 24、
升级 123 全绿;SQL 列名静态校验 0 处可疑;部署 11 条线上探针全过。
This commit is contained in:
@@ -0,0 +1,32 @@
|
|||||||
|
# 家书 FamilyBook · Apache 重写规则
|
||||||
|
#
|
||||||
|
# ⚠️ 这个文件**不随包上线**(familybook_push.sh 的打包白名单里没有它)。
|
||||||
|
# 原因:线上站点根那份 .htaccess 是**宝塔面板生成的**(PHP 版本段、防盗链、
|
||||||
|
# 缓存段、伪静态…),整份覆盖会把面板的东西清空,而且面板下次重写还会改回来,
|
||||||
|
# 变成"时好时坏"。
|
||||||
|
# /dav/ 的重写规则由 `familybook_deploy.sh` 第 5c 段**追加**到线上那份里
|
||||||
|
# (带 `# >>> FamilyBook CalDAV` 标记,幂等)。
|
||||||
|
#
|
||||||
|
# 那这份文件是干什么用的?
|
||||||
|
# · 新建站点(没有宝塔那层)时,可直接把它作为站点根的 .htaccess;
|
||||||
|
# · 它是那段规则的**唯一书面来源**,push 脚本会断言部署脚本里的规则串
|
||||||
|
# 与它完全一致(防两处漂移)。
|
||||||
|
#
|
||||||
|
# 只做一件事:把 /dav/ 开头的 URL 交给 dav.php。
|
||||||
|
# 其余一律不动 —— index.php 那套 ?r=xxx 路由不依赖重写,
|
||||||
|
# 少写规则就少一处"上线才发现 404"的地方。
|
||||||
|
|
||||||
|
<IfModule mod_rewrite.c>
|
||||||
|
RewriteEngine On
|
||||||
|
|
||||||
|
# CalDAV:/dav/、/dav/3.ics/、/dav/3.ics/12.ics 全部交给 dav.php
|
||||||
|
RewriteRule ^dav(/.*)?$ dav.php [L,QSA]
|
||||||
|
</IfModule>
|
||||||
|
|
||||||
|
# 备份/临时文件永远不给出去(这条线上很要紧:*.php.bak 曾经可被直接下载)
|
||||||
|
<FilesMatch "\.(bak|old|orig|save|swp|swo|tmp)$">
|
||||||
|
Require all denied
|
||||||
|
</FilesMatch>
|
||||||
|
<FilesMatch "^\.|~$">
|
||||||
|
Require all denied
|
||||||
|
</FilesMatch>
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* 纪念日(v24 用户第 13 条)
|
||||||
|
*
|
||||||
|
* 用户原话:「增加纪念日模块,可以根据阴历和阳历来设置纪念日,在纪念日模块中,
|
||||||
|
* 可以默认看到已经保存的名册中的人的各种生日,生日本身就是纪念日的一种。
|
||||||
|
* 所有的纪念日,都显示在日历的日程中,作为一个全天日程出现。
|
||||||
|
* 纪念日,需要按照阴历和阳历进行重复。」
|
||||||
|
*
|
||||||
|
* 三条设计决定:
|
||||||
|
*
|
||||||
|
* ① **生日不落这张表**,而是像观影/旅程那样**实时派生**。
|
||||||
|
* 理由:生日已经存在 persons(birthday / birthday_lunar),再复制一份到
|
||||||
|
* anniversaries 就会出现"改了生日,纪念日那边还是旧的"。派生出来,
|
||||||
|
* 改生日当天纪念日跟着变 —— 与"日历里观影/旅程是实时派生"同一套口径。
|
||||||
|
*
|
||||||
|
* ② 用户自建的纪念日才落库(anniversaries 表)。`repeat_type` 决定怎么"年年重现":
|
||||||
|
* solar = 每年公历的同月同日;lunar = 每年农历的同月同日(所以库里存的是
|
||||||
|
* **农历的月/日**,不是公历换算结果 —— 换算结果只对某一年有效)。
|
||||||
|
*
|
||||||
|
* ③ 进日历、而且是**全天**日程(用户明确要求)。所以渲染时 all_day=1、
|
||||||
|
* 时间部分留空。
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App;
|
||||||
|
|
||||||
|
final class Anniversary
|
||||||
|
{
|
||||||
|
/** 重复方式 */
|
||||||
|
public const REPEAT = [
|
||||||
|
'solar' => '每年(阳历)',
|
||||||
|
'lunar' => '每年(农历)',
|
||||||
|
'once' => '只此一次',
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 庆典类纪念日大致的分类,给个默认值方便挑选(用户可自由填) */
|
||||||
|
public const PRESET_KINDS = ['生日', '结婚纪念日', '相识纪念日', '忌日', '节日', '其它'];
|
||||||
|
|
||||||
|
public const ATTACH = 'anniversary';
|
||||||
|
|
||||||
|
// ---------------- ① 用户自建的纪念日 ----------------
|
||||||
|
|
||||||
|
public static function list(int $familyId): array
|
||||||
|
{
|
||||||
|
$st = Database::get()->prepare(
|
||||||
|
'SELECT * FROM anniversaries WHERE family_id = ? ORDER BY month, day, id'
|
||||||
|
);
|
||||||
|
$st->execute([$familyId]);
|
||||||
|
return $st->fetchAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function find(int $familyId, int $id): ?array
|
||||||
|
{
|
||||||
|
$st = Database::get()->prepare('SELECT * FROM anniversaries WHERE id = ? AND family_id = ?');
|
||||||
|
$st->execute([$id, $familyId]);
|
||||||
|
$r = $st->fetch();
|
||||||
|
return $r ?: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 新建 / 修改一条纪念日。
|
||||||
|
*
|
||||||
|
* @param array $d title / repeat_type / month / day / kind / note / person_id / leap
|
||||||
|
* @return int 记录 id
|
||||||
|
*/
|
||||||
|
public static function save(int $familyId, array $d, int $id = 0): int
|
||||||
|
{
|
||||||
|
$db = Database::get();
|
||||||
|
$title = trim((string)($d['title'] ?? ''));
|
||||||
|
if ($title === '') {
|
||||||
|
$title = '纪念日';
|
||||||
|
}
|
||||||
|
$repeat = (string)($d['repeat_type'] ?? 'solar');
|
||||||
|
if (!isset(self::REPEAT[$repeat])) {
|
||||||
|
$repeat = 'solar';
|
||||||
|
}
|
||||||
|
$month = max(1, min(12, (int)($d['month'] ?? 1)));
|
||||||
|
$day = max(1, min(31, (int)($d['day'] ?? 1)));
|
||||||
|
$kind = mb_substr(trim((string)($d['kind'] ?? '')), 0, 40, 'UTF-8');
|
||||||
|
$note = trim((string)($d['note'] ?? ''));
|
||||||
|
$pid = (int)($d['person_id'] ?? 0) ?: null;
|
||||||
|
$leap = !empty($d['leap']) ? 1 : 0;
|
||||||
|
|
||||||
|
if ($id > 0) {
|
||||||
|
$db->prepare(
|
||||||
|
'UPDATE anniversaries SET title=?, repeat_type=?, month=?, day=?, is_leap=?,
|
||||||
|
kind=?, note=?, person_id=? WHERE id=? AND family_id=?'
|
||||||
|
)->execute([$title, $repeat, $month, $day, $leap, $kind !== '' ? $kind : null,
|
||||||
|
$note !== '' ? $note : null, $pid, $id, $familyId]);
|
||||||
|
return $id;
|
||||||
|
}
|
||||||
|
$db->prepare(
|
||||||
|
'INSERT INTO anniversaries (family_id, title, repeat_type, month, day, is_leap,
|
||||||
|
kind, note, person_id)
|
||||||
|
VALUES (?,?,?,?,?,?,?,?,?)'
|
||||||
|
)->execute([$familyId, $title, $repeat, $month, $day, $leap,
|
||||||
|
$kind !== '' ? $kind : null, $note !== '' ? $note : null, $pid]);
|
||||||
|
return (int)$db->lastInsertId();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function remove(int $familyId, int $id): void
|
||||||
|
{
|
||||||
|
Attachment::removeAll(self::ATTACH, $id);
|
||||||
|
Database::get()->prepare('DELETE FROM anniversaries WHERE id = ? AND family_id = ?')
|
||||||
|
->execute([$id, $familyId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- ② 派生:名册里每个人的生日 ----------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把名册里所有人的生日,当成"纪念日"派生出来。
|
||||||
|
*
|
||||||
|
* 用户原话:「生日本身就是纪念日的一种」——所以这里不去 persons 里写任何东西,
|
||||||
|
* 只读出生日、包成与自建纪念日**同一个形状**的行,上层(列表页 / 日历)一视同仁。
|
||||||
|
*
|
||||||
|
* @return array<int,array> 每项含 title/kind/month/day/repeat_type/person_id/source/…
|
||||||
|
*/
|
||||||
|
public static function birthdays(int $familyId, int $viewerId): array
|
||||||
|
{
|
||||||
|
$people = Person::map($familyId);
|
||||||
|
$out = [];
|
||||||
|
foreach ($people as $pid => $p) {
|
||||||
|
$pid = (int)$pid;
|
||||||
|
// 阳历生日(列名叫 birthday,读取一律走 Birthday::solarOf)
|
||||||
|
$solar = Birthday::dateOnly(Birthday::solarOf($p));
|
||||||
|
if ($solar !== '') {
|
||||||
|
$out[] = [
|
||||||
|
'id' => 0,
|
||||||
|
'title' => (string)($p['name'] ?? '') . ' 的生日',
|
||||||
|
'kind' => '生日',
|
||||||
|
'repeat_type' => 'solar',
|
||||||
|
'month' => (int)substr($solar, 5, 2),
|
||||||
|
'day' => (int)substr($solar, 8, 2),
|
||||||
|
'is_leap' => 0,
|
||||||
|
'person_id' => $pid,
|
||||||
|
'person_name' => (string)($p['name'] ?? ''),
|
||||||
|
'source' => 'birthday_solar',
|
||||||
|
'born' => $solar,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
// 农历生日(**日期部分是农历**,所以月/日是农历的月日)
|
||||||
|
$lunar = Birthday::dateOnly((string)($p['birthday_lunar'] ?? ''));
|
||||||
|
if ($lunar !== '') {
|
||||||
|
$out[] = [
|
||||||
|
'id' => 0,
|
||||||
|
'title' => (string)($p['name'] ?? '') . ' 的农历生日',
|
||||||
|
'kind' => '生日',
|
||||||
|
'repeat_type' => 'lunar',
|
||||||
|
'month' => (int)substr($lunar, 5, 2),
|
||||||
|
'day' => (int)substr($lunar, 8, 2),
|
||||||
|
'is_leap' => (int)($p['birthday_lunar_leap'] ?? 0),
|
||||||
|
'person_id' => $pid,
|
||||||
|
'person_name' => (string)($p['name'] ?? ''),
|
||||||
|
'source' => 'birthday_lunar',
|
||||||
|
'born' => $lunar,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unset($viewerId);
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 自建纪念日 + 派生生日,合成一份"全部纪念日" */
|
||||||
|
public static function all(int $familyId, int $viewerId): array
|
||||||
|
{
|
||||||
|
$out = self::birthdays($familyId, $viewerId);
|
||||||
|
foreach (self::list($familyId) as $r) {
|
||||||
|
$r['source'] = 'custom';
|
||||||
|
$r['person_name'] = '';
|
||||||
|
$out[] = $r;
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- ③ 这一年会落在哪几天(给日历用) ----------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 某一年里,这些纪念日各落在哪个公历日期。
|
||||||
|
*
|
||||||
|
* ⚠️ 农历那一路必须**逐年换算**:农历新年在公历 1~2 月,
|
||||||
|
* 所以同一条"农历冬月初一"在不同年份对应的公历日期完全不同,
|
||||||
|
* 不存在"存一个公历日期年年用"的可能。
|
||||||
|
*
|
||||||
|
* @return array<int,array{date:string,title:string,days:int,kind:string,person_id:int,source:string,age:?int}>
|
||||||
|
*/
|
||||||
|
public static function occurrences(int $familyId, int $viewerId, int $year): array
|
||||||
|
{
|
||||||
|
$out = [];
|
||||||
|
foreach (self::all($familyId, $viewerId) as $a) {
|
||||||
|
$repeat = (string)($a['repeat_type'] ?? 'solar');
|
||||||
|
$m = (int)($a['month'] ?? 0);
|
||||||
|
$d = (int)($a['day'] ?? 0);
|
||||||
|
if ($m < 1 || $m > 12 || $d < 1 || $d > 31) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$date = null;
|
||||||
|
if ($repeat === 'lunar') {
|
||||||
|
$leap = !empty($a['is_leap']) && Lunar::leapMonth($year) === $m;
|
||||||
|
$sol = Lunar::lunarToSolar($year, $m, $d, $leap);
|
||||||
|
if ($sol === null) {
|
||||||
|
// 这一年这个月没有这一天(如腊月三十遇上 29 天的腊月)→ 退到当月最后一天
|
||||||
|
$max = $leap ? Lunar::leapDays($year) : Lunar::monthDays($year, $m);
|
||||||
|
$sol = Lunar::lunarToSolar($year, $m, max(1, min($d, $max)), $leap);
|
||||||
|
}
|
||||||
|
if ($sol === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$date = sprintf('%04d-%02d-%02d', $sol['year'], $sol['month'], $sol['day']);
|
||||||
|
} else {
|
||||||
|
// 公历(含"只此一次"):2/29 落在平年时退到 2/28
|
||||||
|
$dd = $d;
|
||||||
|
if (!checkdate($m, $dd, $year)) {
|
||||||
|
$dd = min($dd, (int)date('t', mktime(0, 0, 0, $m, 1, $year)));
|
||||||
|
}
|
||||||
|
$date = sprintf('%04d-%02d-%02d', $year, $m, $dd);
|
||||||
|
}
|
||||||
|
$age = null;
|
||||||
|
if (($a['source'] ?? '') !== 'custom' && !empty($a['born'])) {
|
||||||
|
$age = Birthday::ageOn((string)$a['born'], $date);
|
||||||
|
}
|
||||||
|
$out[] = [
|
||||||
|
'date' => $date,
|
||||||
|
'title' => (string)($a['title'] ?? ''),
|
||||||
|
'kind' => (string)($a['kind'] ?? ''),
|
||||||
|
'person_id' => (int)($a['person_id'] ?? 0),
|
||||||
|
'source' => (string)($a['source'] ?? 'custom'),
|
||||||
|
'note' => (string)($a['note'] ?? ''),
|
||||||
|
'id' => (int)($a['id'] ?? 0),
|
||||||
|
'age' => $age,
|
||||||
|
'repeat' => $repeat,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 一段时间内的纪念日(日历画格子用)。
|
||||||
|
* @return array<int,array> 按日期升序
|
||||||
|
*/
|
||||||
|
public static function between(int $familyId, int $viewerId, string $from, string $to): array
|
||||||
|
{
|
||||||
|
$y1 = (int)substr($from, 0, 4);
|
||||||
|
$y2 = (int)substr($to, 0, 4);
|
||||||
|
$out = [];
|
||||||
|
for ($y = $y1; $y <= $y2; $y++) {
|
||||||
|
foreach (self::occurrences($familyId, $viewerId, $y) as $o) {
|
||||||
|
if ($o['date'] >= $from && $o['date'] <= $to) {
|
||||||
|
$out[] = $o;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
usort($out, static fn($a, $b) => strcmp($a['date'], $b['date']));
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 接下来 n 天内的纪念日(列表页头部"临近"用) */
|
||||||
|
public static function upcoming(int $familyId, int $viewerId, int $withinDays = 60): array
|
||||||
|
{
|
||||||
|
$today = date('Y-m-d');
|
||||||
|
$to = date('Y-m-d', strtotime('+' . max(1, $withinDays) . ' days'));
|
||||||
|
$rows = self::between($familyId, $viewerId, $today, $to);
|
||||||
|
foreach ($rows as &$r) {
|
||||||
|
$r['days'] = (int)((strtotime($r['date']) - strtotime($today)) / 86400);
|
||||||
|
}
|
||||||
|
unset($r);
|
||||||
|
return $rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function repeatWord(string $k): string
|
||||||
|
{
|
||||||
|
return self::REPEAT[$k] ?? $k;
|
||||||
|
}
|
||||||
|
}
|
||||||
+88
-3
@@ -128,13 +128,98 @@ final class Attachment
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 能在浏览器里直接预览吗(图片 / PDF) */
|
/**
|
||||||
public static function previewable(array $a): bool
|
* 这个附件是「哪一类」—— 决定页面上画缩略图还是画图标、点开是预览还是下载。
|
||||||
|
*
|
||||||
|
* 返回:image / video / pdf / doc / xls / ppt / text / file
|
||||||
|
*
|
||||||
|
* ⚠️ 判据是**库里的 mime**(上传时由服务端嗅探内容得来),不看文件名后缀:
|
||||||
|
* 改个后缀骗不过它,而"文档其实是张图"这种也认得出来。
|
||||||
|
*/
|
||||||
|
public static function kind(array $a): string
|
||||||
{
|
{
|
||||||
if ((int)($a['is_image'] ?? 0) === 1) {
|
if ((int)($a['is_image'] ?? 0) === 1) {
|
||||||
|
return 'image';
|
||||||
|
}
|
||||||
|
$mime = strtolower((string)($a['mime'] ?? ''));
|
||||||
|
$name = strtolower((string)($a['name'] ?? ''));
|
||||||
|
$ext = strtolower(pathinfo($name, PATHINFO_EXTENSION));
|
||||||
|
if ($mime === '' && $ext !== '') {
|
||||||
|
$mime = self::EXT_MIME[$ext] ?? '';
|
||||||
|
}
|
||||||
|
if (strpos($mime, 'video/') === 0) {
|
||||||
|
return 'video';
|
||||||
|
}
|
||||||
|
if (strpos($mime, 'audio/') === 0) {
|
||||||
|
return 'video'; // 音频用播放器放,与视频同一条路
|
||||||
|
}
|
||||||
|
if (strpos($mime, 'pdf') !== false || $ext === 'pdf') {
|
||||||
|
return 'pdf';
|
||||||
|
}
|
||||||
|
if (strpos($mime, 'text/') === 0 || in_array($ext, ['txt', 'md', 'csv', 'log', 'json'], true)) {
|
||||||
|
return 'text';
|
||||||
|
}
|
||||||
|
// Office 的 MIME 一长串且各家写法不一(含 -msword / officedocument / opendocument),
|
||||||
|
// 扩展名反而更稳,所以这里两条路都认。
|
||||||
|
if (self::inAny($mime . ' ' . $ext, ['word', 'doc', 'rtf', 'odt', 'wps'])) {
|
||||||
|
return 'doc';
|
||||||
|
}
|
||||||
|
if (self::inAny($mime . ' ' . $ext, ['excel', 'sheet', 'xls', 'xlsx', 'csv', 'ods', 'et'])) {
|
||||||
|
return 'xls';
|
||||||
|
}
|
||||||
|
if (self::inAny($mime . ' ' . $ext, ['powerpoint', 'presentation', 'ppt', 'pps', 'odp', 'dps'])) {
|
||||||
|
return 'ppt';
|
||||||
|
}
|
||||||
|
return 'file';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 能在**本页**直接预览吗。
|
||||||
|
*
|
||||||
|
* 图片与视频不用说;PDF 浏览器自带阅读器,也能直接看。
|
||||||
|
* Word / Excel 之类浏览器渲染不了 —— 点了就下载(不装"点了没反应"的预览)。
|
||||||
|
*/
|
||||||
|
public static function previewable(array $a): bool
|
||||||
|
{
|
||||||
|
return in_array(self::kind($a), ['image', 'video', 'pdf'], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 这一类在人话里叫什么(图标上的角标、预览标题都用它) */
|
||||||
|
public static function kindWord(string $kind): string
|
||||||
|
{
|
||||||
|
return self::KIND_WORDS[$kind] ?? '文件';
|
||||||
|
}
|
||||||
|
|
||||||
|
private const KIND_WORDS = [
|
||||||
|
'image' => '图片', 'video' => '视频', 'pdf' => 'PDF',
|
||||||
|
'doc' => 'Word', 'xls' => 'Excel', 'ppt' => 'PPT',
|
||||||
|
'text' => '文本', 'file' => '文件',
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 扩展名兜底用的 MIME 表(库里 mime 为空时) */
|
||||||
|
private const EXT_MIME = [
|
||||||
|
'pdf' => 'application/pdf', 'txt' => 'text/plain', 'md' => 'text/markdown',
|
||||||
|
'csv' => 'text/csv', 'json' => 'application/json',
|
||||||
|
'mp4' => 'video/mp4', 'm4v' => 'video/mp4', 'webm' => 'video/webm',
|
||||||
|
'mov' => 'video/quicktime', 'avi' => 'video/x-msvideo', 'mkv' => 'video/x-matroska',
|
||||||
|
'mp3' => 'audio/mpeg', 'm4a' => 'audio/mp4', 'wav' => 'audio/wav',
|
||||||
|
'doc' => 'application/msword',
|
||||||
|
'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||||
|
'xls' => 'application/vnd.ms-excel',
|
||||||
|
'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
|
'ppt' => 'application/vnd.ms-powerpoint',
|
||||||
|
'pptx' => 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||||
|
'zip' => 'application/zip', 'rar' => 'application/x-rar-compressed',
|
||||||
|
];
|
||||||
|
|
||||||
|
private static function inAny(string $hay, array $needles): bool
|
||||||
|
{
|
||||||
|
foreach ($needles as $n) {
|
||||||
|
if (strpos($hay, $n) !== false) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return strpos((string)($a['mime'] ?? ''), 'pdf') !== false;
|
}
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 人话的大小 */
|
/** 人话的大小 */
|
||||||
|
|||||||
+103
@@ -38,6 +38,109 @@ final class Auth
|
|||||||
return $_SESSION['name'] ?? '';
|
return $_SESSION['name'] ?? '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------- CalDAV 专用口令(v24 第 7 条后半) ----------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 这个人有没有配过 CalDAV 口令。
|
||||||
|
*
|
||||||
|
* ⚠️ 为什么不用登录口令:CalDAV 客户端(iOS 日历 / DAVx⁵ / Thunderbird)
|
||||||
|
* 会把这串凭据**长期存在设备上**并反复自动发送。让它是登录口令,
|
||||||
|
* 等于把主密码复制到每一台设备、且改密码就要每台重配。
|
||||||
|
* 所以另发一个只用于日历订阅的口令,随时可换、与登录互不影响。
|
||||||
|
*/
|
||||||
|
public static function hasCalDav(int $personId): bool
|
||||||
|
{
|
||||||
|
$st = Database::get()->prepare('SELECT COUNT(*) FROM caldav_credentials WHERE person_id = ?');
|
||||||
|
$st->execute([$personId]);
|
||||||
|
return (int)$st->fetchColumn() > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成(或重置)CalDAV 口令,**返回明文**(只在这一刻能看到,库里只有哈希)。
|
||||||
|
*/
|
||||||
|
public static function caldavIssue(int $personId): string
|
||||||
|
{
|
||||||
|
// 去掉容易读错的字符(0/O、1/l/I),因为用户要手打进手机
|
||||||
|
$alphabet = 'abcdefghjkmnpqrstuvwxyz23456789';
|
||||||
|
$plain = '';
|
||||||
|
for ($i = 0; $i < 24; $i++) {
|
||||||
|
$plain .= $alphabet[random_int(0, strlen($alphabet) - 1)];
|
||||||
|
}
|
||||||
|
Database::get()->prepare(
|
||||||
|
'INSERT INTO caldav_credentials (person_id, token_hash) VALUES (?,?)
|
||||||
|
ON DUPLICATE KEY UPDATE token_hash = VALUES(token_hash), created_at = NOW()'
|
||||||
|
)->execute([$personId, hash('sha256', $plain)]);
|
||||||
|
return $plain;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function caldavRevoke(int $personId): void
|
||||||
|
{
|
||||||
|
Database::get()->prepare('DELETE FROM caldav_credentials WHERE person_id = ?')
|
||||||
|
->execute([$personId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用 Basic 凭据换一个「登录者」。成功返回 [personId, familyId, name],失败 null。
|
||||||
|
*
|
||||||
|
* 用户名可以是手机号,也可以是姓名 —— 手机上打姓名比打 11 位数字省事得多。
|
||||||
|
* 两者都走同一套节流(失败计数保存在会话里,与登录共用)。
|
||||||
|
*/
|
||||||
|
public static function caldavAuth(?string $user, ?string $pass): ?array
|
||||||
|
{
|
||||||
|
$user = trim((string)$user);
|
||||||
|
$pass = (string)$pass;
|
||||||
|
if ($user === '' || $pass === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$fail = $_SESSION['cd_fail'] ?? ['n' => 0, 't' => 0];
|
||||||
|
if ($fail['n'] >= 8 && (time() - $fail['t']) < 60) {
|
||||||
|
usleep(500000);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$db = Database::get();
|
||||||
|
// 先按手机号(哈希)找,再按姓名找 —— 都限定 status=active
|
||||||
|
$row = null;
|
||||||
|
$st = $db->prepare(
|
||||||
|
'SELECT a.id AS account_id, p.id AS person_id, p.family_id, p.name, cd.token_hash
|
||||||
|
FROM accounts a
|
||||||
|
JOIN persons p ON p.id = a.person_id
|
||||||
|
LEFT JOIN caldav_credentials cd ON cd.person_id = p.id
|
||||||
|
WHERE a.status = "active" AND p.phone_hash = ? LIMIT 1'
|
||||||
|
);
|
||||||
|
$st->execute([Crypto::hash($user)]);
|
||||||
|
$row = $st->fetch();
|
||||||
|
if (!$row) {
|
||||||
|
$st = $db->prepare(
|
||||||
|
'SELECT a.id AS account_id, p.id AS person_id, p.family_id, p.name, cd.token_hash
|
||||||
|
FROM accounts a
|
||||||
|
JOIN persons p ON p.id = a.person_id
|
||||||
|
LEFT JOIN caldav_credentials cd ON cd.person_id = p.id
|
||||||
|
WHERE a.status = "active" AND p.name = ?
|
||||||
|
ORDER BY p.id LIMIT 1'
|
||||||
|
);
|
||||||
|
$st->execute([$user]);
|
||||||
|
$row = $st->fetch();
|
||||||
|
}
|
||||||
|
|
||||||
|
$ok = $row && !empty($row['token_hash'])
|
||||||
|
&& hash_equals((string)$row['token_hash'], hash('sha256', $pass));
|
||||||
|
if (!$ok) {
|
||||||
|
$_SESSION['cd_fail'] = ['n' => $fail['n'] + 1, 't' => time()];
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
unset($_SESSION['cd_fail']);
|
||||||
|
$db->prepare('UPDATE caldav_credentials SET last_used_at = NOW() WHERE person_id = ?')
|
||||||
|
->execute([(int)$row['person_id']]);
|
||||||
|
return [
|
||||||
|
'person_id' => (int)$row['person_id'],
|
||||||
|
'family_id' => (int)$row['family_id'],
|
||||||
|
'name' => (string)$row['name'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
public static function isAdmin(): bool
|
public static function isAdmin(): bool
|
||||||
{
|
{
|
||||||
return ($_SESSION['role'] ?? '') === 'admin';
|
return ($_SESSION['role'] ?? '') === 'admin';
|
||||||
|
|||||||
+20
-11
@@ -1,8 +1,12 @@
|
|||||||
<?php
|
<?php
|
||||||
/**
|
/**
|
||||||
* 三个生日:身份证生日 / 真实阳历生日 / 农历生日。
|
* 生日这一块:证件上的那天(只读、不显示)/ 真实阳历生日 / 农历生日。
|
||||||
*
|
*
|
||||||
* 为什么要有三个(而不是把生日当一个字段):
|
* ⚠️ v24(用户 2026-10-02 第 16 条):**身份证生日默认不显示**。
|
||||||
|
* 它照常存库(户籍事实,永不变),但不出现在任何页面与任何提醒里,
|
||||||
|
* 对外只保留"阳历生日"与"农历生日"两种真实生日。
|
||||||
|
*
|
||||||
|
* 为什么要有这些(而不是把生日当一个字段):
|
||||||
* · 身份证上的那个是**户籍事实**,和证件完全一致,任何人都不该在系统里改它 ——
|
* · 身份证上的那个是**户籍事实**,和证件完全一致,任何人都不该在系统里改它 ——
|
||||||
* 它和真实生日**经常不一样**(当年上户口随手写的、只报农历的、报错的)。
|
* 它和真实生日**经常不一样**(当年上户口随手写的、只报农历的、报错的)。
|
||||||
* · 真实阳历生日是"实际过的那天",是给本人过的。
|
* · 真实阳历生日是"实际过的那天",是给本人过的。
|
||||||
@@ -29,9 +33,15 @@ namespace App;
|
|||||||
|
|
||||||
final class Birthday
|
final class Birthday
|
||||||
{
|
{
|
||||||
/** 三种生日的键 => 人话名字 */
|
/**
|
||||||
|
* 会**往外显示**的生日种类 => 人话名字。
|
||||||
|
*
|
||||||
|
* ⚠️ v24(用户 2026-10-02 第 16 条):这里原本有三档,含身份证生日。
|
||||||
|
* 现在只剩阳历与农历两种 —— 身份证生日仍然照常存库
|
||||||
|
* (`birthday_id_card`,只读、永不变),但**任何地方都不再显示它**:
|
||||||
|
* 它常常和真实生日不是同一天,两个日期并列摆着反而让人分不清"到底哪天过生日"。
|
||||||
|
*/
|
||||||
public const KINDS = [
|
public const KINDS = [
|
||||||
'id' => '身份证生日',
|
|
||||||
'solar' => '阳历生日',
|
'solar' => '阳历生日',
|
||||||
'lunar' => '农历生日',
|
'lunar' => '农历生日',
|
||||||
];
|
];
|
||||||
@@ -259,10 +269,8 @@ final class Birthday
|
|||||||
$lunarBd = self::dateOnly($person['birthday_lunar'] ?? '');
|
$lunarBd = self::dateOnly($person['birthday_lunar'] ?? '');
|
||||||
$lunarLeap = !empty($person['birthday_lunar_leap']);
|
$lunarLeap = !empty($person['birthday_lunar_leap']);
|
||||||
|
|
||||||
if ($idBd !== '') {
|
// v24 第 16 条:身份证生日不再参与"接下来要过哪个生日"(理由见 describe())。
|
||||||
$d = self::nextSolarDate($idBd, $today);
|
// 它仍然会照常存库,只是不再产出提醒与日历条目。
|
||||||
$buckets[$d]['kinds'][] = 'id';
|
|
||||||
}
|
|
||||||
if ($solarBd !== '') {
|
if ($solarBd !== '') {
|
||||||
$d = self::nextSolarDate($solarBd, $today);
|
$d = self::nextSolarDate($solarBd, $today);
|
||||||
$buckets[$d]['kinds'][] = 'solar';
|
$buckets[$d]['kinds'][] = 'solar';
|
||||||
@@ -339,9 +347,10 @@ final class Birthday
|
|||||||
$solarBd = self::dateOnly($solarRaw);
|
$solarBd = self::dateOnly($solarRaw);
|
||||||
$lunarBd = self::dateOnly($person['birthday_lunar'] ?? '');
|
$lunarBd = self::dateOnly($person['birthday_lunar'] ?? '');
|
||||||
|
|
||||||
if ($idBd !== '') {
|
// ⚠️ v24(用户 2026-10-02 第 16 条):**身份证生日默认不显示**。
|
||||||
$rows[] = ['kind' => 'id', 'text' => $idBd, 'note' => '由身份证推导,不可修改', 'date' => $idBd];
|
// 它只是"证件上的那一天",常常和真实生日不是同一天,两个日期并列摆在详情页
|
||||||
}
|
// 反而让人分不清"到底哪天过生日"。现在只显示阳历与农历两种真实生日。
|
||||||
|
// 身份证生日仍然照常在库里(birthday_id_card 只读、永不变),只是不往外显示。
|
||||||
if ($solarBd !== '') {
|
if ($solarBd !== '') {
|
||||||
$rows[] = ['kind' => 'solar', 'text' => self::pretty($solarRaw), 'note' => '', 'date' => $solarBd];
|
$rows[] = ['kind' => 'solar', 'text' => self::pretty($solarRaw), 'note' => '', 'date' => $solarBd];
|
||||||
}
|
}
|
||||||
|
|||||||
+142
@@ -0,0 +1,142 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* CalDAV 服务端(v24 用户第 7 条后半)
|
||||||
|
*
|
||||||
|
* 用户原话:「再增加caldav功能,就是可以把属于我的日历本,通过caldav协议分享给别人。」
|
||||||
|
*
|
||||||
|
* 实现的是 RFC 4791 的一个**只读子集** —— 够 iOS / 安卓 / Thunderbird / DAVx⁵
|
||||||
|
* 把家书的日历本订阅进来;本阶段不做 PUT/DELETE(在手机上改日程)。
|
||||||
|
* 少做这一半的代价是"要处理冲突与 ETag 回写",而收益(随时改)并不急。
|
||||||
|
*
|
||||||
|
* 客户端会依次发这些请求(这是**必须**按顺序实现的最小集):
|
||||||
|
*
|
||||||
|
* OPTIONS /dav/ → 声明支持 DAV: 1, calendar-access
|
||||||
|
* PROPFIND /dav/ (Depth: 0) → 当前用户可见的日历本清单
|
||||||
|
* PROPFIND /dav/<book>.ics/ (0) → 某一本的属性(displayname / color / 组件集)
|
||||||
|
* PROPFIND /dav/<book>.ics/ (1) → 这本里所有日程的资源清单(getetag)
|
||||||
|
* REPORT /dav/<book>.ics/ → calendar-query / calendar-multiget,取正文
|
||||||
|
* GET /dav/<book>.ics/ → 整本导出成一个 ics(很多客户端走这条)
|
||||||
|
*
|
||||||
|
* 鉴权用 **HTTP Basic**(DAVx⁵ / iOS 都原生支持),口令不是登录口令:
|
||||||
|
* 见 Auth::caldavToken() —— 登录口令绝不该在客户端里被反复存着。
|
||||||
|
*
|
||||||
|
* ⚠️ 只读:所有写方法(PUT/DELETE/PROPPATCH)一律 403。
|
||||||
|
* 与其"半支持"(写成功但客户端状态与服务端不一致),不如明确拒绝。
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App;
|
||||||
|
|
||||||
|
final class CalDav
|
||||||
|
{
|
||||||
|
/** 资源前缀(URL 里的路径) */
|
||||||
|
public const ROOT = '/dav/';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 当前用户可见的日历本。与「日程」模块里看到的是同一批
|
||||||
|
* (我建的 + 共享给我的),口径必须一致,否则手机上少一本、网页上多一本。
|
||||||
|
*/
|
||||||
|
public static function books(int $familyId, int $personId): array
|
||||||
|
{
|
||||||
|
return Calendar::mine($familyId, $personId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 日历本 → URL 段。用 id 而不是名字:名字里有中文/空格/斜杠时,
|
||||||
|
* 客户端与服务端对"怎么编码"的理解常常不一致(实测 DAVx⁵ 会二次编码)。
|
||||||
|
*/
|
||||||
|
public static function hrefOf(array $cal): string
|
||||||
|
{
|
||||||
|
return self::ROOT . (int)$cal['id'] . '.ics/';
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function bookHref(int $calId): string
|
||||||
|
{
|
||||||
|
return self::ROOT . $calId . '.ics/';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从 URL 段(如 /dav/3.ics/)解出日历本 id 与"是不是要单个日程" */
|
||||||
|
public static function parsePath(string $path): array
|
||||||
|
{
|
||||||
|
$p = (string)parse_url($path, PHP_URL_PATH);
|
||||||
|
if (strpos($p, self::ROOT) !== 0) {
|
||||||
|
return ['book' => 0, 'event' => 0];
|
||||||
|
}
|
||||||
|
$rest = substr($p, strlen(self::ROOT));
|
||||||
|
$rest = trim($rest, '/');
|
||||||
|
if ($rest === '') {
|
||||||
|
return ['book' => 0, 'event' => 0];
|
||||||
|
}
|
||||||
|
// 形如 3.ics 或 3.ics/<uid>.ics
|
||||||
|
$parts = explode('/', $rest);
|
||||||
|
$bookSeg = $parts[0];
|
||||||
|
$book = (int)preg_replace('/\.ics$/', '', $bookSeg);
|
||||||
|
$event = 0;
|
||||||
|
if (isset($parts[1]) && $parts[1] !== '') {
|
||||||
|
// 单条日程:<uid>.ics —— uid 里可能含点,所以只去掉结尾的 .ics
|
||||||
|
$evSeg = preg_replace('/\.ics$/', '', $parts[1]);
|
||||||
|
$event = (int)$evSeg; // 我们用的是 calendar_events.id
|
||||||
|
}
|
||||||
|
return ['book' => $book, 'event' => $event];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- 应答 ----------------
|
||||||
|
|
||||||
|
public static function xmlHeader(): void
|
||||||
|
{
|
||||||
|
header('Content-Type: application/xml; charset=utf-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 多状态应答(207)—— PROPFIND / REPORT 都用它 */
|
||||||
|
public static function multis(int $code = 207): void
|
||||||
|
{
|
||||||
|
http_response_code($code);
|
||||||
|
self::xmlHeader();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 把一个 xml 文本片段做最省事的转义(我们只回显自己库里的字符串) */
|
||||||
|
public static function x(string $s): string
|
||||||
|
{
|
||||||
|
return htmlspecialchars($s, ENT_XML1 | ENT_QUOTES, 'UTF-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 某个日历本里"能列出来"的日程。
|
||||||
|
*
|
||||||
|
* ⚠️ 与网页日历同源:导出/订阅的内容必须与页面上看到的一致。
|
||||||
|
* 派生本(观影 / 旅程)走 Calendar::exportRows,自定义本走 calendar_events。
|
||||||
|
*/
|
||||||
|
public static function rows(array $cal, int $familyId, int $me): array
|
||||||
|
{
|
||||||
|
return Calendar::exportRows($cal, $familyId, $me);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 单条日程(按 calendar_events.id)—— 只允许自定义本,派生本没有"单条资源" */
|
||||||
|
public static function oneEvent(int $calId, int $eventId, array $familyIdAndMe): ?array
|
||||||
|
{
|
||||||
|
[$familyId, $me] = $familyIdAndMe;
|
||||||
|
$st = Database::get()->prepare(
|
||||||
|
'SELECT e.* FROM calendar_events e
|
||||||
|
JOIN calendars c ON c.id = e.calendar_id
|
||||||
|
WHERE e.id = ? AND e.calendar_id = ? AND c.family_id = ?'
|
||||||
|
);
|
||||||
|
$st->execute([$eventId, $calId, $familyId]);
|
||||||
|
$r = $st->fetch();
|
||||||
|
if (!$r) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// 共享只读的人也要能取到(能看见这本就能看见里面的条目)
|
||||||
|
$cal = Calendar::mineOne($familyId, $calId, $me);
|
||||||
|
if (!$cal) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return $r;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 一整本导出成 ics 文本(GET 与 REPORT 都用它) */
|
||||||
|
public static function ics(array $cal, int $familyId, int $me): string
|
||||||
|
{
|
||||||
|
return Ics::export(self::rows($cal, $familyId, $me), (string)$cal['name']);
|
||||||
|
}
|
||||||
|
}
|
||||||
+290
-30
@@ -19,6 +19,9 @@ namespace App;
|
|||||||
|
|
||||||
final class Calendar
|
final class Calendar
|
||||||
{
|
{
|
||||||
|
/** 日程附件的宿主类型(统一附件表用;v24 第 6 条:日程也能挂附件) */
|
||||||
|
public const ATTACH = 'calendar_event';
|
||||||
|
|
||||||
/** 系统预置的两本默认日历(kind 与名字都写死,用户的自定义本一律 kind='custom') */
|
/** 系统预置的两本默认日历(kind 与名字都写死,用户的自定义本一律 kind='custom') */
|
||||||
public const DEFAULT_KINDS = [
|
public const DEFAULT_KINDS = [
|
||||||
'movie' => ['观影', '#7A4E8C'],
|
'movie' => ['观影', '#7A4E8C'],
|
||||||
@@ -32,9 +35,30 @@ final class Calendar
|
|||||||
* 太浅看不清字,太艳会盖过整个月视图。
|
* 太浅看不清字,太艳会盖过整个月视图。
|
||||||
*/
|
*/
|
||||||
public const PRESET_CAL_COLORS = [
|
public const PRESET_CAL_COLORS = [
|
||||||
'#4A7C82', '#7A4E8C', '#3F7D5A', '#B0763A', '#5566A8',
|
'#A63030', '#A65C30', '#A68930', '#97A630',
|
||||||
'#A8574F', '#8A6B4B', '#5C8A48', '#9A5C7A', '#3D6B8E',
|
'#6BA630', '#3FA630', '#30A64E', '#30A67A',
|
||||||
'#8C7A3F', '#6B5B95',
|
'#30A6A6', '#307AA6', '#304EA6', '#3F30A6',
|
||||||
|
'#6B30A6', '#9730A6', '#A63089', '#A6305C',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 提醒档位:提前多久(分钟)—— 对应 RFC 5545 的 VALARM / TRIGGER。
|
||||||
|
*
|
||||||
|
* ⚠️ 顺序就是用户 2026-10-02 第 6 条原话的顺序,别按数值大小重排:
|
||||||
|
* 下拉里"5 分钟、10 分钟、15 分钟…1 天"读起来是递进的,
|
||||||
|
* 按数值排也一样,但一改动顺序用户就会以为自己选错了。
|
||||||
|
*/
|
||||||
|
public const REMINDERS = [
|
||||||
|
5 => '提前 5 分钟',
|
||||||
|
10 => '提前 10 分钟',
|
||||||
|
15 => '提前 15 分钟',
|
||||||
|
30 => '提前 30 分钟',
|
||||||
|
60 => '提前 1 小时',
|
||||||
|
120 => '提前 2 小时',
|
||||||
|
240 => '提前 4 小时',
|
||||||
|
480 => '提前 8 小时',
|
||||||
|
720 => '提前 12 小时',
|
||||||
|
1440 => '提前 1 天',
|
||||||
];
|
];
|
||||||
|
|
||||||
/** 新建一本 */
|
/** 新建一本 */
|
||||||
@@ -120,6 +144,23 @@ final class Calendar
|
|||||||
return ($cal['perm'] ?? '') === 'write';
|
return ($cal['perm'] ?? '') === 'write';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按 id 取一本「我能看到的」日历(带 perm / shared)。
|
||||||
|
*
|
||||||
|
* ⚠️ 判权限时**必须**走这里而不是 Calendar::find():find() 返回的是 calendars
|
||||||
|
* 表原行,里面没有 perm —— 于是「共享给我的、可读写的本子」一律被判成不能写,
|
||||||
|
* 被共享的人建了日程也改不了(蹲在 calendar/event 页上看只读提示)。
|
||||||
|
*/
|
||||||
|
public static function mineOne(int $familyId, int $personId, int $calendarId): ?array
|
||||||
|
{
|
||||||
|
foreach (self::mine($familyId, $personId) as $c) {
|
||||||
|
if ((int)$c['id'] === $calendarId) {
|
||||||
|
return $c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/** 给某人补建两本默认日历(幂等) */
|
/** 给某人补建两本默认日历(幂等) */
|
||||||
public static function seedDefaults(int $familyId, int $personId): void
|
public static function seedDefaults(int $familyId, int $personId): void
|
||||||
{
|
{
|
||||||
@@ -176,39 +217,63 @@ final class Calendar
|
|||||||
|
|
||||||
// ---------------- 日程 ----------------
|
// ---------------- 日程 ----------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 新建一条日程。
|
||||||
|
*
|
||||||
|
* ⚠️ v24(第 5、6 条):起止是**完整的 DATETIME(精确到秒)**,不再分成
|
||||||
|
* "日期一列 + 时刻一列" —— 分开存就表达不了"今天 22:30 到明天 01:15",
|
||||||
|
* 而且用户填的时候也得分两处。全天日程仍然写 DATETIME,只是时间部分是 00:00:00。
|
||||||
|
* ⚠️ uid 一定要在**插入时**生成:CalDAV 客户端靠 UID 认日程,
|
||||||
|
* 没有 UID 的日程每同步一次就被当成新日程再建一条(无限重复)。
|
||||||
|
*/
|
||||||
public static function addEvent(int $calendarId, array $d): int
|
public static function addEvent(int $calendarId, array $d): int
|
||||||
{
|
{
|
||||||
$db = Database::get();
|
$db = Database::get();
|
||||||
$db->prepare(
|
$db->prepare(
|
||||||
'INSERT INTO calendar_events
|
'INSERT INTO calendar_events
|
||||||
(calendar_id, title, starts_at, ends_at, start_time, end_time, all_day, note, created_by)
|
(calendar_id, uid, title, location, starts_at, ends_at, all_day,
|
||||||
VALUES (?,?,?,?,?,?,?,?,?)'
|
reminder_minutes, status, transp, rrule, note, created_by)
|
||||||
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)'
|
||||||
)->execute([
|
)->execute([
|
||||||
$calendarId,
|
$calendarId,
|
||||||
|
self::newUid(),
|
||||||
mb_substr(trim((string)$d['title']), 0, 200, 'UTF-8'),
|
mb_substr(trim((string)$d['title']), 0, 200, 'UTF-8'),
|
||||||
|
self::textOrNull((string)($d['location'] ?? ''), 255),
|
||||||
$d['starts_at'],
|
$d['starts_at'],
|
||||||
$d['ends_at'] ?: null,
|
$d['ends_at'] ?: null,
|
||||||
$d['start_time'] ?: null,
|
|
||||||
$d['end_time'] ?: null,
|
|
||||||
!empty($d['all_day']) ? 1 : 0,
|
!empty($d['all_day']) ? 1 : 0,
|
||||||
|
self::reminderMinutes($d['reminder_minutes'] ?? null),
|
||||||
|
self::statusOf($d['status'] ?? null),
|
||||||
|
!empty($d['transp']) ? 1 : 0,
|
||||||
|
self::textOrNull((string)($d['rrule'] ?? ''), 255),
|
||||||
self::textOrNull($d['note'] ?? ''),
|
self::textOrNull($d['note'] ?? ''),
|
||||||
$d['created_by'] ?: null,
|
$d['created_by'] ?: null,
|
||||||
]);
|
]);
|
||||||
return (int)$db->lastInsertId();
|
return (int)$db->lastInsertId();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 改一条日程。
|
||||||
|
*
|
||||||
|
* ⚠️ SEQUENCE 每次 +1(RFC 5545):CalDAV 客户端拿它判断"服务端这版比我新吗"。
|
||||||
|
* 不加的话,改完日程客户端可能还抱着旧的不放。
|
||||||
|
*/
|
||||||
public static function updateEvent(int $id, array $d): void
|
public static function updateEvent(int $id, array $d): void
|
||||||
{
|
{
|
||||||
Database::get()->prepare(
|
Database::get()->prepare(
|
||||||
'UPDATE calendar_events SET title=?, starts_at=?, ends_at=?, start_time=?, end_time=?,
|
'UPDATE calendar_events SET title=?, location=?, starts_at=?, ends_at=?, all_day=?,
|
||||||
all_day=?, note=? WHERE id=?'
|
reminder_minutes=?, status=?, transp=?, rrule=?, note=?, sequence = sequence + 1
|
||||||
|
WHERE id=?'
|
||||||
)->execute([
|
)->execute([
|
||||||
mb_substr(trim((string)$d['title']), 0, 200, 'UTF-8'),
|
mb_substr(trim((string)$d['title']), 0, 200, 'UTF-8'),
|
||||||
|
self::textOrNull((string)($d['location'] ?? ''), 255),
|
||||||
$d['starts_at'],
|
$d['starts_at'],
|
||||||
$d['ends_at'] ?: null,
|
$d['ends_at'] ?: null,
|
||||||
$d['start_time'] ?: null,
|
|
||||||
$d['end_time'] ?: null,
|
|
||||||
!empty($d['all_day']) ? 1 : 0,
|
!empty($d['all_day']) ? 1 : 0,
|
||||||
|
self::reminderMinutes($d['reminder_minutes'] ?? null),
|
||||||
|
self::statusOf($d['status'] ?? null),
|
||||||
|
!empty($d['transp']) ? 1 : 0,
|
||||||
|
self::textOrNull((string)($d['rrule'] ?? ''), 255),
|
||||||
self::textOrNull($d['note'] ?? ''),
|
self::textOrNull($d['note'] ?? ''),
|
||||||
$id,
|
$id,
|
||||||
]);
|
]);
|
||||||
@@ -216,9 +281,165 @@ final class Calendar
|
|||||||
|
|
||||||
public static function deleteEvent(int $id): void
|
public static function deleteEvent(int $id): void
|
||||||
{
|
{
|
||||||
|
Attachment::removeAll(self::ATTACH, $id);
|
||||||
Database::get()->prepare('DELETE FROM calendar_events WHERE id = ?')->execute([$id]);
|
Database::get()->prepare('DELETE FROM calendar_events WHERE id = ?')->execute([$id]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 一条日程(含所属本子;用来判权限) */
|
||||||
|
public static function event(int $id): ?array
|
||||||
|
{
|
||||||
|
$st = Database::get()->prepare(
|
||||||
|
'SELECT e.*, c.owner_id, c.name AS cal_name, c.color AS cal_color, c.kind AS cal_kind
|
||||||
|
FROM calendar_events e JOIN calendars c ON c.id = e.calendar_id
|
||||||
|
WHERE e.id = ?'
|
||||||
|
);
|
||||||
|
$st->execute([$id]);
|
||||||
|
$r = $st->fetch();
|
||||||
|
return $r ?: null; // 「能不能写」另走 canWriteEvent()(要按人查共享表,塞不进这一行)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 这条日程我能写吗(按它所在本子的归属与共享权限判)。
|
||||||
|
*
|
||||||
|
* ⚠️ 单独开一个方法而不是让调用方自己拼数组给 canWrite():
|
||||||
|
* canWrite() 吃的是「日历本」那一行(要带 perm),拿日程行直接喂进去
|
||||||
|
* 会因为没有 perm 而恒判成不能写。
|
||||||
|
*/
|
||||||
|
public static function canWriteEvent(array $event, int $personId): bool
|
||||||
|
{
|
||||||
|
if ((int)($event['owner_id'] ?? 0) === $personId) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
$st = Database::get()->prepare(
|
||||||
|
'SELECT perm FROM calendar_shares WHERE calendar_id = ? AND person_id = ?'
|
||||||
|
);
|
||||||
|
$st->execute([(int)$event['calendar_id'], $personId]);
|
||||||
|
return (string)$st->fetchColumn() === 'write';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 一条日程的 UID(CalDAV 那边按 UID 取) */
|
||||||
|
public static function newUid(): string
|
||||||
|
{
|
||||||
|
return bin2hex(random_bytes(12)) . '@familybook';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导出 ics 用的行集(用户 2026-10-02 第 7 条)。
|
||||||
|
*
|
||||||
|
* 三本日历的内容来源不一样,但**导出时都要有东西**:
|
||||||
|
* · custom → calendar_events 里真存着的自定义日程;
|
||||||
|
* · movie → events(kind=movie) 派生;
|
||||||
|
* · trip → trip_nodes 派生(与日历视图同一口径:出节点,不出整趟旅程)。
|
||||||
|
* 统一成 calendar_events 那一套字段名(starts_at/ends_at/title/...),
|
||||||
|
* Ics 那边就不必分辨来源是哪种。
|
||||||
|
*
|
||||||
|
* @return array<int,array>
|
||||||
|
*/
|
||||||
|
public static function exportRows(array $cal, int $familyId, int $me): array
|
||||||
|
{
|
||||||
|
$kind = (string)$cal['kind'];
|
||||||
|
if ($kind === 'movie' || $kind === 'trip') {
|
||||||
|
// 派生本:先按一个足够宽的范围取日历视图,再把每一条映射成可导出的行
|
||||||
|
$from = '1970-01-01';
|
||||||
|
$to = '2100-01-01';
|
||||||
|
$out = [];
|
||||||
|
$seen = [];
|
||||||
|
foreach (self::month($familyId, [$cal], $me, $from, $to) as $e) {
|
||||||
|
$key = $e['kind'] . '-' . $e['ref_id'] . '-' . $e['date'];
|
||||||
|
if (isset($seen[$key])) {
|
||||||
|
continue; // 跨天日程会逐天出一条,导出时一条就够
|
||||||
|
}
|
||||||
|
$seen[$key] = true;
|
||||||
|
$day = (string)$e['date'];
|
||||||
|
$out[] = [
|
||||||
|
'uid' => $e['kind'] . '-' . (int)$e['ref_id'] . '@familybook',
|
||||||
|
'title' => (string)$e['title'],
|
||||||
|
'starts_at' => $day . ' ' . ($e['time'] !== '' ? $e['time'] . ':00' : '00:00:00'),
|
||||||
|
'ends_at' => null,
|
||||||
|
'all_day' => empty($e['time']) ? 1 : 0,
|
||||||
|
'location' => null,
|
||||||
|
'note' => null,
|
||||||
|
'status' => 'confirmed',
|
||||||
|
'transp' => 0,
|
||||||
|
'sequence' => 0,
|
||||||
|
'created_at' => null,
|
||||||
|
'updated_at' => null,
|
||||||
|
'reminder_minutes' => null,
|
||||||
|
'rrule' => null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
$st = Database::get()->prepare(
|
||||||
|
'SELECT * FROM calendar_events WHERE calendar_id = ? ORDER BY starts_at'
|
||||||
|
);
|
||||||
|
$st->execute([(int)$cal['id']]);
|
||||||
|
return $st->fetchAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导入一条 ics 里的 VEVENT。
|
||||||
|
*
|
||||||
|
* ⚠️ 判重用 UID + 日历本:同一本日历里 UID 已存在就**更新**而不是再插一条 ——
|
||||||
|
* 否则同一个 .ics 导入两次,日历里就是两份一模一样的日程(用户最常见的操作
|
||||||
|
* "导一次没看清,再导一次"会立刻翻车)。
|
||||||
|
*
|
||||||
|
* @return string 'added' | 'updated'
|
||||||
|
*/
|
||||||
|
public static function importEvent(int $calendarId, array $v, int $createdBy): string
|
||||||
|
{
|
||||||
|
$db = Database::get();
|
||||||
|
$uid = (string)($v['uid'] ?? '');
|
||||||
|
if ($uid !== '') {
|
||||||
|
$st = $db->prepare('SELECT id FROM calendar_events WHERE calendar_id = ? AND uid = ?');
|
||||||
|
$st->execute([$calendarId, $uid]);
|
||||||
|
$old = (int)$st->fetchColumn();
|
||||||
|
if ($old > 0) {
|
||||||
|
self::updateEvent($old, $v);
|
||||||
|
return 'updated';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$uid = self::newUid();
|
||||||
|
}
|
||||||
|
$db->prepare(
|
||||||
|
'INSERT INTO calendar_events
|
||||||
|
(calendar_id, uid, title, location, starts_at, ends_at, all_day,
|
||||||
|
reminder_minutes, status, transp, rrule, note, created_by)
|
||||||
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)'
|
||||||
|
)->execute([
|
||||||
|
$calendarId,
|
||||||
|
$uid,
|
||||||
|
mb_substr(trim((string)($v['title'] ?? '')), 0, 200, 'UTF-8'),
|
||||||
|
self::textOrNull((string)($v['location'] ?? ''), 255),
|
||||||
|
$v['starts_at'],
|
||||||
|
$v['ends_at'] ?: null,
|
||||||
|
!empty($v['all_day']) ? 1 : 0,
|
||||||
|
self::reminderMinutes($v['reminder_minutes'] ?? null),
|
||||||
|
self::statusOf($v['status'] ?? null),
|
||||||
|
0,
|
||||||
|
self::textOrNull((string)($v['rrule'] ?? ''), 255),
|
||||||
|
self::textOrNull((string)($v['note'] ?? '')),
|
||||||
|
$createdBy ?: null,
|
||||||
|
]);
|
||||||
|
return 'added';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 提醒分钟数:只认预设档位与 0/NULL,乱传的落 NULL(不提醒) */
|
||||||
|
private static function reminderMinutes($v): ?int
|
||||||
|
{
|
||||||
|
if ($v === null || $v === '' || $v === false) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$n = (int)$v;
|
||||||
|
return isset(self::REMINDERS[$n]) ? $n : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function statusOf($v): string
|
||||||
|
{
|
||||||
|
$v = (string)$v;
|
||||||
|
return in_array($v, ['confirmed', 'tentative', 'cancelled'], true) ? $v : 'confirmed';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 某个月(或某段日期)里**全部**日程:
|
* 某个月(或某段日期)里**全部**日程:
|
||||||
* 自定义日程(calendar_events)+ 观影(events kind=movie)+ 旅行(events kind=trip)
|
* 自定义日程(calendar_events)+ 观影(events kind=movie)+ 旅行(events kind=trip)
|
||||||
@@ -249,8 +470,9 @@ final class Calendar
|
|||||||
$in = implode(',', array_fill(0, count($ids), '?'));
|
$in = implode(',', array_fill(0, count($ids), '?'));
|
||||||
$st = Database::get()->prepare(
|
$st = Database::get()->prepare(
|
||||||
"SELECT * FROM calendar_events
|
"SELECT * FROM calendar_events
|
||||||
WHERE calendar_id IN ($in) AND starts_at <= ? AND COALESCE(ends_at, starts_at) >= ?
|
WHERE calendar_id IN ($in)
|
||||||
ORDER BY starts_at, start_time"
|
AND DATE(starts_at) <= ? AND DATE(COALESCE(ends_at, starts_at)) >= ?
|
||||||
|
ORDER BY starts_at"
|
||||||
);
|
);
|
||||||
$st->execute(array_merge($ids, [$to, $from]));
|
$st->execute(array_merge($ids, [$to, $from]));
|
||||||
$calById = [];
|
$calById = [];
|
||||||
@@ -259,21 +481,26 @@ final class Calendar
|
|||||||
}
|
}
|
||||||
foreach ($st->fetchAll() as $e) {
|
foreach ($st->fetchAll() as $e) {
|
||||||
$c = $calById[(int)$e['calendar_id']] ?? null;
|
$c = $calById[(int)$e['calendar_id']] ?? null;
|
||||||
$end = $e['ends_at'] ?: $e['starts_at'];
|
$sd = substr((string)$e['starts_at'], 0, 10);
|
||||||
|
$ed = substr((string)($e['ends_at'] ?: $e['starts_at']), 0, 10);
|
||||||
// 跨天日程:在范围内**每天**都出一条(群晖日历就是这么画的)
|
// 跨天日程:在范围内**每天**都出一条(群晖日历就是这么画的)
|
||||||
foreach (self::eachDay((string)$e['starts_at'], (string)$end, $from, $to) as $day) {
|
$first = true;
|
||||||
|
foreach (self::eachDay($sd, $ed, $from, $to) as $day) {
|
||||||
|
$tm = substr((string)$e['starts_at'], 11, 5);
|
||||||
$out[] = [
|
$out[] = [
|
||||||
'cal' => (int)$e['calendar_id'],
|
'cal' => (int)$e['calendar_id'],
|
||||||
'color' => (string)($c['color'] ?? '#4A7C82'),
|
'color' => (string)($c['color'] ?? '#4A7C82'),
|
||||||
'calname' => (string)($c['name'] ?? ''),
|
'calname' => (string)($c['name'] ?? ''),
|
||||||
'title' => (string)$e['title'],
|
'title' => (string)$e['title'],
|
||||||
'date' => $day,
|
'date' => $day,
|
||||||
'time' => empty($e['all_day']) ? substr((string)$e['start_time'], 0, 5) : '',
|
// 跨天的第二、三天只显示"还在进行",不再重复挂一个开始时刻
|
||||||
|
'time' => ($first && empty($e['all_day'])) ? $tm : '',
|
||||||
'all_day' => !empty($e['all_day']),
|
'all_day' => !empty($e['all_day']),
|
||||||
'kind' => 'custom',
|
'kind' => 'custom',
|
||||||
'ref_id' => (int)$e['id'],
|
'ref_id' => (int)$e['id'],
|
||||||
'href' => url('calendar/event', ['id' => (int)$e['id']]),
|
'href' => url('calendar/event', ['id' => (int)$e['id']]),
|
||||||
];
|
];
|
||||||
|
$first = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -306,32 +533,46 @@ final class Calendar
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- 旅行(events kind=trip)----
|
// ---- 旅程(trip_nodes,不是 events)----
|
||||||
|
// ⚠️ v24(用户 2026-10-02 第 2 条):日历里出的是**行程节点**,不是「这趟旅程」本身。
|
||||||
|
// 原来把整趟旅程摊成一条跨天全天日程,于是日历上一整周都被"云南之行"占满,
|
||||||
|
// 真正要看的"几点出发 / 几点到景点"全都看不见 —— 旅程是个容器,节点才是日程。
|
||||||
|
// 现在按 trip_nodes.starts_at 出条目,跨天的节点同样逐天铺开。
|
||||||
if (isset($byKind['trip'])) {
|
if (isset($byKind['trip'])) {
|
||||||
$c = $byKind['trip'][0];
|
$c = $byKind['trip'][0];
|
||||||
$st = Database::get()->prepare(
|
$st = Database::get()->prepare(
|
||||||
"SELECT id, title, starts_at, ends_at FROM events
|
"SELECT n.id AS node_id, n.title AS node_title, n.starts_at, n.ends_at,
|
||||||
WHERE family_id = ? AND kind = 'trip' AND starts_at IS NOT NULL
|
n.node_type, n.status AS node_status,
|
||||||
AND DATE(starts_at) <= ? AND COALESCE(DATE(ends_at), DATE(starts_at)) >= ?
|
e.id AS event_id, e.title AS trip_title
|
||||||
ORDER BY starts_at"
|
FROM trip_nodes n
|
||||||
|
JOIN events e ON e.id = n.event_id AND e.kind = 'trip'
|
||||||
|
WHERE n.family_id = ? AND n.starts_at IS NOT NULL
|
||||||
|
AND n.status <> 'cancelled'
|
||||||
|
AND DATE(n.starts_at) <= ? AND COALESCE(DATE(n.ends_at), DATE(n.starts_at)) >= ?
|
||||||
|
ORDER BY n.starts_at"
|
||||||
);
|
);
|
||||||
$st->execute([$familyId, $to, $from]);
|
$st->execute([$familyId, $to, $from]);
|
||||||
foreach ($st->fetchAll() as $e) {
|
foreach ($st->fetchAll() as $n) {
|
||||||
foreach (self::eachDay(substr((string)$e['starts_at'], 0, 10),
|
$start = substr((string)$n['starts_at'], 0, 10);
|
||||||
substr((string)($e['ends_at'] ?: $e['starts_at']), 0, 10),
|
$end = substr((string)($n['ends_at'] ?: $n['starts_at']), 0, 10);
|
||||||
$from, $to) as $day) {
|
// 节点跨天时只在**第一天**显示时间(后面的天是"还在进行",不是"又开始了")
|
||||||
|
$tm = substr((string)$n['starts_at'], 11, 5);
|
||||||
|
$first = true;
|
||||||
|
foreach (self::eachDay($start, $end, $from, $to) as $day) {
|
||||||
$out[] = [
|
$out[] = [
|
||||||
'cal' => (int)$c['id'],
|
'cal' => (int)$c['id'],
|
||||||
'color' => (string)$c['color'],
|
'color' => (string)$c['color'],
|
||||||
'calname' => (string)$c['name'],
|
'calname' => (string)$c['name'],
|
||||||
'title' => '🧳 ' . (string)$e['title'],
|
'title' => Trip::typeIcon((string)$n['node_type']) . ' ' . (string)$n['node_title'],
|
||||||
'date' => $day,
|
'date' => $day,
|
||||||
'time' => '',
|
'time' => $first ? ($tm === '00:00' ? '' : $tm) : '',
|
||||||
'all_day' => true,
|
'all_day' => $first && $tm === '00:00',
|
||||||
'kind' => 'trip',
|
'kind' => 'trip',
|
||||||
'ref_id' => (int)$e['id'],
|
'ref_id' => (int)$n['node_id'],
|
||||||
'href' => url('trips/show', ['id' => (int)$e['id']]),
|
'href' => url('trips/show', ['id' => (int)$n['event_id']])
|
||||||
|
. '#node-' . (int)$n['node_id'],
|
||||||
];
|
];
|
||||||
|
$first = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -364,6 +605,25 @@ final class Calendar
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- 纪念日(v24 第 13 条)----
|
||||||
|
// 用户明确要求:纪念日一律作为**全天日程**出现在日历里。
|
||||||
|
// 数据是**实时派生**的(生日读 persons、农历逐年换算),所以这里不查表、直接算。
|
||||||
|
// ⚠️ 农历那一路必须按"这一年"换算 —— 同一条农历生日在不同年份落在不同公历日期。
|
||||||
|
foreach (Anniversary::between($familyId, $personId, $from, $to) as $a) {
|
||||||
|
$out[] = [
|
||||||
|
'cal' => 0,
|
||||||
|
'color' => '#B4633C',
|
||||||
|
'calname' => '纪念日',
|
||||||
|
'title' => '🎂 ' . (string)$a['title'],
|
||||||
|
'date' => (string)$a['date'],
|
||||||
|
'time' => '',
|
||||||
|
'all_day' => true, // 全天(用户点名要求)
|
||||||
|
'kind' => 'anniv',
|
||||||
|
'ref_id' => (int)($a['id'] ?? 0),
|
||||||
|
'href' => $a['id'] > 0 ? url('days/edit', ['id' => (int)$a['id']]) : url('days'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
usort($out, static function ($a, $b) {
|
usort($out, static function ($a, $b) {
|
||||||
return strcmp($a['date'], $b['date'])
|
return strcmp($a['date'], $b['date'])
|
||||||
?: strcmp((string)$a['time'], (string)$b['time'])
|
?: strcmp((string)$a['time'], (string)$b['time'])
|
||||||
|
|||||||
+402
@@ -0,0 +1,402 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* iCalendar(RFC 5545)的读与写 —— 日程的 ics 导入 / 导出(用户 2026-10-02 第 7 条)。
|
||||||
|
*
|
||||||
|
* 为什么自己写而不引一个库:
|
||||||
|
* 这个应用是「零依赖」的(连 Composer 都没有),为两个函数拖进一个依赖不划算;
|
||||||
|
* 而且我们只需要 VEVENT 的一个常用子集,自己写反而能把"哪些字段往返不丢"说清楚。
|
||||||
|
*
|
||||||
|
* 三条必须遵守的 RFC 规则(踩过就懂):
|
||||||
|
* ① **行要折**:一行超过 75 个**字节**(不是字符!中文一个字 3 字节)就必须折,
|
||||||
|
* 折行用 CRLF + 一个空格开头。不折的话 iOS 日历会整条拒收。
|
||||||
|
* ② **文本要转义**:\ ; , 和换行各有转义写法,不转义会把一行正文切成好几条属性。
|
||||||
|
* ③ **全天日程的 DTSTART 是 DATE 值**(20261015),不是 DATETIME ——
|
||||||
|
* 写成 DATETIME 的话,客户端会当成"这一天的 00:00",看着一样但跨时区会错。
|
||||||
|
*
|
||||||
|
* 时区:系统里存的一律是**本地时间**(不带时区),导出也不带 Z、不写 VTIMEZONE。
|
||||||
|
* 家庭应用里"几点开会"就是墙上那个钟,硬套 UTC 反而会整体偏移。
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App;
|
||||||
|
|
||||||
|
final class Ics
|
||||||
|
{
|
||||||
|
/** 导出的产品标识(RFC 5545 PRODID,客户端会拿它判断"是谁生成的") */
|
||||||
|
private const PRODID = '-//FamilyBook//FamilyBook Calendar//CN';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把一个日历本导出成 .ics 文本。
|
||||||
|
*
|
||||||
|
* @param array $events calendar_events 的行
|
||||||
|
* @param string $calName 日历本名字(X-WR-CALNAME,客户端用它给订阅起标题)
|
||||||
|
*/
|
||||||
|
public static function export(array $events, string $calName = '日程'): string
|
||||||
|
{
|
||||||
|
$out = [
|
||||||
|
'BEGIN:VCALENDAR',
|
||||||
|
'VERSION:2.0',
|
||||||
|
'PRODID:' . self::PRODID,
|
||||||
|
'CALSCALE:GREGORIAN',
|
||||||
|
'METHOD:PUBLISH',
|
||||||
|
'X-WR-CALNAME:' . self::escapeText($calName),
|
||||||
|
];
|
||||||
|
foreach ($events as $e) {
|
||||||
|
$out = array_merge($out, self::vevent($e));
|
||||||
|
}
|
||||||
|
$out[] = 'END:VCALENDAR';
|
||||||
|
// 折行 + 统一 CRLF(RFC 5545 要求行尾是 CRLF,虽然多数客户端也收 LF)
|
||||||
|
$lines = [];
|
||||||
|
foreach ($out as $line) {
|
||||||
|
foreach (self::fold($line) as $l) {
|
||||||
|
$lines[] = $l;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return implode("\r\n", $lines) . "\r\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 一条日程 → VEVENT 的若干行 */
|
||||||
|
private static function vevent(array $e): array
|
||||||
|
{
|
||||||
|
$allDay = !empty($e['all_day']);
|
||||||
|
$starts = (string)($e['starts_at'] ?? '');
|
||||||
|
$ends = (string)($e['ends_at'] ?? '');
|
||||||
|
$uid = (string)($e['uid'] ?? '');
|
||||||
|
if ($uid === '') {
|
||||||
|
$uid = Calendar::newUid();
|
||||||
|
}
|
||||||
|
$stamp = self::stampOf($e['updated_at'] ?? $e['created_at'] ?? null);
|
||||||
|
|
||||||
|
$out = ['BEGIN:VEVENT', 'UID:' . $uid, 'DTSTAMP:' . $stamp];
|
||||||
|
|
||||||
|
if ($allDay) {
|
||||||
|
// 全天:VALUE=DATE,且 DTEND 是**第二天**(RFC:DTEND 是排他边界)
|
||||||
|
$out[] = 'DTSTART;VALUE=DATE:' . self::dateValue($starts);
|
||||||
|
$d = self::dateValue($ends !== '' ? $ends : $starts);
|
||||||
|
$out[] = 'DTEND;VALUE=DATE:' . ($ends !== '' ? self::nextDay($d) : self::nextDay($d));
|
||||||
|
} else {
|
||||||
|
$out[] = 'DTSTART:' . self::dateTimeValue($starts);
|
||||||
|
// 没有结束时刻就按开始时刻算(RFC 允许只给 DTSTART + DURATION,
|
||||||
|
// 但很多客户端对 DURATION 支持得更差,给一个等于开始的 DTEND 最稳)
|
||||||
|
$out[] = 'DTEND:' . self::dateTimeValue($ends !== '' ? $ends : $starts);
|
||||||
|
}
|
||||||
|
|
||||||
|
$out[] = 'SUMMARY:' . self::escapeText((string)($e['title'] ?? ''));
|
||||||
|
if (($e['location'] ?? '') !== '') {
|
||||||
|
$out[] = 'LOCATION:' . self::escapeText((string)$e['location']);
|
||||||
|
}
|
||||||
|
if (($e['note'] ?? '') !== '') {
|
||||||
|
$out[] = 'DESCRIPTION:' . self::escapeText((string)$e['note']);
|
||||||
|
}
|
||||||
|
if (($e['rrule'] ?? '') !== '') {
|
||||||
|
$out[] = (string)$e['rrule']; // 原样带过(导入时存的就是原文)
|
||||||
|
}
|
||||||
|
$out[] = 'STATUS:' . strtoupper((string)($e['status'] ?: 'confirmed'));
|
||||||
|
$out[] = 'TRANSP:' . (empty($e['transp']) ? 'OPAQUE' : 'TRANSPARENT');
|
||||||
|
$out[] = 'SEQUENCE:' . (int)($e['sequence'] ?? 0);
|
||||||
|
if (!empty($e['created_at'])) {
|
||||||
|
$out[] = 'CREATED:' . self::stampOf($e['created_at']);
|
||||||
|
}
|
||||||
|
if (!empty($e['updated_at'])) {
|
||||||
|
$out[] = 'LAST-MODIFIED:' . self::stampOf($e['updated_at']);
|
||||||
|
}
|
||||||
|
// 提醒 = VALARM(TRIGGER 用**负**持续时间表示"提前多久")
|
||||||
|
if (($e['reminder_minutes'] ?? null) !== null && (int)$e['reminder_minutes'] > 0) {
|
||||||
|
$out = array_merge($out, [
|
||||||
|
'BEGIN:VALARM',
|
||||||
|
'ACTION:DISPLAY',
|
||||||
|
'DESCRIPTION:' . self::escapeText((string)($e['title'] ?? '提醒')),
|
||||||
|
'TRIGGER:-' . self::duration((int)$e['reminder_minutes']),
|
||||||
|
'END:VALARM',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
$out[] = 'END:VEVENT';
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析一个 .ics 文本,返回里面的 VEVENT。
|
||||||
|
*
|
||||||
|
* @return array<int, array{uid:string,title:string,starts_at:string,ends_at:string,
|
||||||
|
* all_day:bool,location:?string,note:?string,
|
||||||
|
* reminder_minutes:?int,status:string,rrule:?string}>
|
||||||
|
*/
|
||||||
|
public static function parse(string $text): array
|
||||||
|
{
|
||||||
|
$text = str_replace(["\r\n", "\r"], "\n", $text);
|
||||||
|
// ① 反折行:以空格或制表符开头的行是上一行的续行
|
||||||
|
$text = preg_replace("/\n[ \t]/", '', $text) ?? $text;
|
||||||
|
$lines = explode("\n", $text);
|
||||||
|
|
||||||
|
$out = [];
|
||||||
|
$cur = null;
|
||||||
|
$inAlarm = false;
|
||||||
|
foreach ($lines as $raw) {
|
||||||
|
$line = trim($raw);
|
||||||
|
if ($line === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($line === 'BEGIN:VEVENT') {
|
||||||
|
$cur = [];
|
||||||
|
$inAlarm = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($line === 'END:VEVENT') {
|
||||||
|
if ($cur !== null) {
|
||||||
|
$out[] = self::normalizeVevent($cur);
|
||||||
|
}
|
||||||
|
$cur = null;
|
||||||
|
$inAlarm = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($cur === null) {
|
||||||
|
continue; // VCALENDAR / VTIMEZONE 里的东西一律跳过
|
||||||
|
}
|
||||||
|
if ($line === 'BEGIN:VALARM') { $inAlarm = true; continue; }
|
||||||
|
if ($line === 'END:VALARM') { $inAlarm = false; continue; }
|
||||||
|
|
||||||
|
[$name, $params, $value] = self::splitProp($line);
|
||||||
|
if ($name === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// VALARM 里只认 TRIGGER(提前多久);DESCRIPTION 那些是给闹钟弹窗用的,
|
||||||
|
// 混进来会覆盖日程本身的备注(实测:iOS 导出的 VALARM 里也带 DESCRIPTION)。
|
||||||
|
if ($inAlarm) {
|
||||||
|
if ($name === 'TRIGGER') {
|
||||||
|
$cur['_trigger'] = $value;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// 同名属性出现多次时保留第一个(RFC 里多数属性都是单值的)
|
||||||
|
if (!array_key_exists($name, $cur)) {
|
||||||
|
$cur[$name] = $value;
|
||||||
|
}
|
||||||
|
if (!array_key_exists($name . '_PARAMS', $cur)) {
|
||||||
|
$cur[$name . '_PARAMS'] = $params;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 把解析出来的属性归一成我们要落库的形状 */
|
||||||
|
private static function normalizeVevent(array $p): array
|
||||||
|
{
|
||||||
|
$startRaw = (string)($p['DTSTART'] ?? '');
|
||||||
|
$endRaw = (string)($p['DTEND'] ?? '');
|
||||||
|
$params = (string)($p['DTSTART_PARAMS'] ?? '');
|
||||||
|
// 全天:VALUE=DATE,或者值本身就是 8 位的 YYYYMMDD
|
||||||
|
$allDay = stripos($params, 'VALUE=DATE') !== false
|
||||||
|
|| preg_match('/^\d{8}$/', $startRaw) === 1;
|
||||||
|
|
||||||
|
$starts = $allDay ? self::fromDateValue($startRaw) : self::fromDateTimeValue($startRaw);
|
||||||
|
$ends = $endRaw !== ''
|
||||||
|
? ($allDay ? self::fromDateValue($endRaw) : self::fromDateTimeValue($endRaw))
|
||||||
|
: null;
|
||||||
|
// 全天日程的 DTEND 是**排他**的第二天,减回一天才对得上"到哪天为止"
|
||||||
|
if ($allDay && $ends !== null && $starts !== null) {
|
||||||
|
$d = strtotime($ends . ' -1 day');
|
||||||
|
$ends = $d ? date('Y-m-d', $d) . ' 00:00:00' : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'uid' => (string)($p['UID'] ?? ''),
|
||||||
|
'title' => self::unescapeText((string)($p['SUMMARY'] ?? '')),
|
||||||
|
'starts_at' => $starts,
|
||||||
|
'ends_at' => $ends,
|
||||||
|
'all_day' => $allDay,
|
||||||
|
'location' => self::nullOr(self::unescapeText((string)($p['LOCATION'] ?? ''))),
|
||||||
|
'note' => self::nullOr(self::unescapeText((string)($p['DESCRIPTION'] ?? ''))),
|
||||||
|
'rrule' => self::nullOr((string)($p['RRULE'] ?? '')),
|
||||||
|
'status' => strtolower((string)($p['STATUS'] ?? '')) ?: 'confirmed',
|
||||||
|
'reminder_minutes' => self::triggerMinutes((string)($p['_trigger'] ?? '')),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** TRIGGER:-PT15M / TRIGGER:-P1D / TRIGGER;VALUE=DATE-TIME:... → 分钟数 */
|
||||||
|
private static function triggerMinutes(string $v): ?int
|
||||||
|
{
|
||||||
|
$v = trim($v);
|
||||||
|
if ($v === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// 绝大多数客户端导出的是"提前多久"的持续时间(负号开头)
|
||||||
|
if (preg_match('/^-?P(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/', $v, $m)) {
|
||||||
|
$mins = (int)($m[1] ?? 0) * 1440;
|
||||||
|
$mins += (int)($m[2] ?? 0) * 60;
|
||||||
|
$mins += (int)($m[3] ?? 0);
|
||||||
|
$mins += (int)($m[4] ?? 0) > 0 ? 1 : 0; // 有秒就进位到 1 分钟,别归零
|
||||||
|
return $mins > 0 ? $mins : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 分钟 → RFC 5545 的持续时间,如 90 → PT1H30M */
|
||||||
|
private static function duration(int $mins): string
|
||||||
|
{
|
||||||
|
$d = intdiv($mins, 1440);
|
||||||
|
$h = intdiv($mins % 1440, 60);
|
||||||
|
$m = $mins % 60;
|
||||||
|
$s = 'P';
|
||||||
|
if ($d > 0) {
|
||||||
|
$s .= $d . 'D';
|
||||||
|
}
|
||||||
|
if ($h > 0 || $m > 0) {
|
||||||
|
$s .= 'T';
|
||||||
|
if ($h > 0) {
|
||||||
|
$s .= $h . 'H';
|
||||||
|
}
|
||||||
|
if ($m > 0) {
|
||||||
|
$s .= $m . 'M';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $s === 'P' ? 'PT0S' : $s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** '2026-10-16 14:30:00' → '20261016T143000' */
|
||||||
|
private static function dateTimeValue(string $dt): string
|
||||||
|
{
|
||||||
|
$t = strtotime($dt);
|
||||||
|
if ($t === false) {
|
||||||
|
return '19700101T000000';
|
||||||
|
}
|
||||||
|
return date('Ymd\THis', $t);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** '2026-10-16 14:30:00' → '20261016' */
|
||||||
|
private static function dateValue(string $dt): string
|
||||||
|
{
|
||||||
|
$t = strtotime($dt);
|
||||||
|
if ($t === false) {
|
||||||
|
return '19700101';
|
||||||
|
}
|
||||||
|
return date('Ymd', $t);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** '20261016T143000' / '2026-10-16 14:30:00' → '2026-10-16 14:30:00' */
|
||||||
|
private static function fromDateTimeValue(string $v): ?string
|
||||||
|
{
|
||||||
|
$v = trim($v);
|
||||||
|
if (preg_match('/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z?$/', $v, $m)) {
|
||||||
|
return sprintf('%s-%s-%s %s:%s:%s', $m[1], $m[2], $m[3], $m[4], $m[5], $m[6]);
|
||||||
|
}
|
||||||
|
$t = strtotime($v);
|
||||||
|
return $t === false ? null : date('Y-m-d H:i:s', $t);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** '20261016' → '2026-10-16 00:00:00' */
|
||||||
|
private static function fromDateValue(string $v): ?string
|
||||||
|
{
|
||||||
|
if (preg_match('/^(\d{4})(\d{2})(\d{2})$/', trim($v), $m)) {
|
||||||
|
return sprintf('%s-%s-%s 00:00:00', $m[1], $m[2], $m[3]);
|
||||||
|
}
|
||||||
|
$t = strtotime($v);
|
||||||
|
return $t === false ? null : date('Y-m-d 00:00:00', $t);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** '20261016' → '20261017' */
|
||||||
|
private static function nextDay(string $ymd): string
|
||||||
|
{
|
||||||
|
$t = strtotime(substr($ymd, 0, 4) . '-' . substr($ymd, 4, 2) . '-' . substr($ymd, 6, 2) . ' +1 day');
|
||||||
|
return $t === false ? $ymd : date('Ymd', $t);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 属性行 → [名, 参数串, 值](只在**第一个**未加引号的冒号处切) */
|
||||||
|
private static function splitProp(string $line): array
|
||||||
|
{
|
||||||
|
$pos = strpos($line, ':');
|
||||||
|
if ($pos === false) {
|
||||||
|
return ['', '', ''];
|
||||||
|
}
|
||||||
|
$left = substr($line, 0, $pos);
|
||||||
|
$value = substr($line, $pos + 1);
|
||||||
|
if (strpos($left, ';') === false) {
|
||||||
|
return [strtoupper($left), '', $value];
|
||||||
|
}
|
||||||
|
[$name, $params] = explode(';', $left, 2);
|
||||||
|
return [strtoupper($name), $params, $value];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** DTSTAMP / CREATED 一律写成 UTC(RFC 要求带 Z) */
|
||||||
|
private static function stampOf($dt): string
|
||||||
|
{
|
||||||
|
$t = strtotime((string)$dt);
|
||||||
|
if ($t === false) {
|
||||||
|
$t = time();
|
||||||
|
}
|
||||||
|
return gmdate('Ymd\THis\Z', $t);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 文本转义(RFC 5545 3.3.11) */
|
||||||
|
private static function escapeText(string $v): string
|
||||||
|
{
|
||||||
|
$v = str_replace('\\', '\\\\', $v);
|
||||||
|
$v = str_replace(';', '\;', $v);
|
||||||
|
$v = str_replace(',', '\,', $v);
|
||||||
|
$v = str_replace(["\r\n", "\r", "\n"], '\n', $v);
|
||||||
|
return $v;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function unescapeText(string $v): string
|
||||||
|
{
|
||||||
|
$v = str_replace('\n', "\n", $v);
|
||||||
|
$v = str_replace('\N', "\n", $v);
|
||||||
|
$v = str_replace('\,', ',', $v);
|
||||||
|
$v = str_replace('\;', ';', $v);
|
||||||
|
return str_replace('\\\\', '\\', $v);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function nullOr(string $v): ?string
|
||||||
|
{
|
||||||
|
$v = trim($v);
|
||||||
|
return $v === '' ? null : $v;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 折行:一行最多 75 **字节**,且不能在多字节字符中间切断。
|
||||||
|
*
|
||||||
|
* ⚠️ 按字节而不是按字符数:中文一个字 3 字节,按字符数切会切出半个 UTF-8 字,
|
||||||
|
* 客户端解出来就是乱码。续行以**一个空格**开头。
|
||||||
|
*/
|
||||||
|
private static function fold(string $line): array
|
||||||
|
{
|
||||||
|
if (strlen($line) <= 75) {
|
||||||
|
return [$line];
|
||||||
|
}
|
||||||
|
$out = [];
|
||||||
|
$cur = '';
|
||||||
|
$len = 0;
|
||||||
|
$limit = 75;
|
||||||
|
$bytes = strlen($line);
|
||||||
|
$i = 0;
|
||||||
|
while ($i < $bytes) {
|
||||||
|
// 一次取一个完整 UTF-8 字符
|
||||||
|
$ch = $line[$i];
|
||||||
|
$n = 1;
|
||||||
|
$o = ord($ch);
|
||||||
|
if ($o >= 0xF0) { $n = 4; }
|
||||||
|
elseif ($o >= 0xE0) { $n = 3; }
|
||||||
|
elseif ($o >= 0xC0) { $n = 2; }
|
||||||
|
$tok = substr($line, $i, $n);
|
||||||
|
$i += $n;
|
||||||
|
// 续行要以空格开头,所以后续每行实际只能放 74 字节
|
||||||
|
$room = $out === [] ? $limit : $limit - 1;
|
||||||
|
if ($len + $n > $room) {
|
||||||
|
$out[] = $cur;
|
||||||
|
$cur = '';
|
||||||
|
$len = 0;
|
||||||
|
}
|
||||||
|
$cur .= $tok;
|
||||||
|
$len += $n;
|
||||||
|
}
|
||||||
|
if ($cur !== '') {
|
||||||
|
$out[] = $cur;
|
||||||
|
}
|
||||||
|
// 续行补前导空格
|
||||||
|
$res = [];
|
||||||
|
foreach ($out as $k => $v) {
|
||||||
|
$res[] = $k === 0 ? $v : ' ' . $v;
|
||||||
|
}
|
||||||
|
return $res;
|
||||||
|
}
|
||||||
|
}
|
||||||
+150
-1
@@ -246,6 +246,10 @@ final class Kinship
|
|||||||
return ['gen' => [], 'units' => [], 'unit_of' => [], 'me' => $me];
|
return ['gen' => [], 'units' => [], 'unit_of' => [], 'me' => $me];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ⚠️ $up / $down 是**代数标号的上下限**(clamp),不是遍历深度 ——
|
||||||
|
// 走是走遍整个连通图的。所以同一个人可能通过两条路算出不同代数,
|
||||||
|
// 下面取「离我最近」的那一档。
|
||||||
|
|
||||||
// ---- ① 代数:以「我」为 0 代,反复贯穿直到不再有人可加 ----
|
// ---- ① 代数:以「我」为 0 代,反复贯穿直到不再有人可加 ----
|
||||||
$gen = [$me => 0];
|
$gen = [$me => 0];
|
||||||
$seen = [$me => true];
|
$seen = [$me => true];
|
||||||
@@ -312,6 +316,145 @@ final class Kinship
|
|||||||
return ['gen' => $gen, 'units' => $units, 'unit_of' => $unitOf, 'me' => $me];
|
return ['gen' => $gen, 'units' => $units, 'unit_of' => $unitOf, 'me' => $me];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v24 用户第 12 条:谁的姻亲「没画在家谱上」。
|
||||||
|
*
|
||||||
|
* 家谱画的是「同一位祖先的血脉后代 + 各人配偶」,所以**配偶那一侧的
|
||||||
|
* 父母/兄弟姐妹**天然不在图内(洪博为的父母不会出现在以杨家为根的树上)。
|
||||||
|
* 用户要的是:这些人的卡片上给一个入口,点一下就以 TA 为中心重画一张,
|
||||||
|
* 把 TA 那侧的姻亲露出来,再给个「返回」回到原图。
|
||||||
|
*
|
||||||
|
* 判据:**这个人有父母,但父母不在"库实际画出来的那批人"里** →
|
||||||
|
* TA 是有独立亲缘家族的人,点一下就以 TA 为中心重画。
|
||||||
|
*
|
||||||
|
* ⚠️ 这里的关键是"实际画出来的" ≠ "我们遍历到的":
|
||||||
|
* familyMap 为了算称呼会走遍**整个连通图**(连配偶的父母都拉进来),
|
||||||
|
* 但 family-chart 只画「根的祖先链 + 根的后代 + 根的兄弟姐妹 + 这些人的配偶」——
|
||||||
|
* **配偶的父母不在画布上**。拿遍历集合当判据,就会得出"岳父母已经在家谱里了"
|
||||||
|
* 这种与眼睛看到的相反的结论(实测踩到)。
|
||||||
|
*
|
||||||
|
* @param array<int,true> $onTree 遍历到的人(用于确定根与限制范围)
|
||||||
|
* @return array<int, array{name:string, count:int}> 人 id => 提示文案
|
||||||
|
*/
|
||||||
|
public static function branchOuts(array $g, array $people, array $onTree): array
|
||||||
|
{
|
||||||
|
// 本图的根 = 辈分最高的那个人(与 kin/tree 的取法一致)
|
||||||
|
$rootId = 0;
|
||||||
|
foreach (array_keys($onTree) as $id) {
|
||||||
|
$id = (int)$id;
|
||||||
|
if (!isset($people[$id])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$hasParentOnTree = false;
|
||||||
|
foreach (self::parentIds($g, $id) as $p) {
|
||||||
|
if (isset($onTree[(int)$p])) {
|
||||||
|
$hasParentOnTree = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!$hasParentOnTree) {
|
||||||
|
$rootId = $id;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($rootId === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$drawn = self::drawnOf($g, $rootId, $onTree);
|
||||||
|
|
||||||
|
$out = [];
|
||||||
|
foreach (array_keys($drawn) as $id) {
|
||||||
|
$id = (int)$id;
|
||||||
|
if ($id === $rootId || !isset($people[$id])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$parents = self::parentIds($g, $id);
|
||||||
|
if (!$parents) {
|
||||||
|
continue; // 不知道父母的(根、待确认的占位人)没什么可展开
|
||||||
|
}
|
||||||
|
$outside = 0;
|
||||||
|
foreach ($parents as $p) {
|
||||||
|
if (!isset($drawn[(int)$p])) {
|
||||||
|
$outside++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($outside === 0) {
|
||||||
|
continue; // 父母都在画布上,这一侧已经露出来了
|
||||||
|
}
|
||||||
|
$out[$id] = [
|
||||||
|
'name' => (string)($people[$id]['name'] ?? ''),
|
||||||
|
'count' => $outside,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* family-chart 实际会画出来的人(近似复刻它的选择规则)。
|
||||||
|
*
|
||||||
|
* 规则(按库的行为):
|
||||||
|
* ① 根的**祖先链**(一直往上);
|
||||||
|
* ② 根的**后代**(一直往下 —— 库在未设上限时不截断);
|
||||||
|
* ③ 根的**兄弟姐妹**(与根共享父母的人);
|
||||||
|
* ④ 以上所有人的**配偶**(夫妻并排是库的基本单位)。
|
||||||
|
*
|
||||||
|
* ⚠️ **配偶的父母不在其中** —— 这正是"姻亲看不到"的根源,也是本函数存在的理由。
|
||||||
|
*
|
||||||
|
* @param array<int,true> $inScope 只在遍历到的人里挑(脏数据不越界)
|
||||||
|
* @return array<int,true>
|
||||||
|
*/
|
||||||
|
private static function drawnOf(array $g, int $rootId, array $inScope): array
|
||||||
|
{
|
||||||
|
$drawn = [];
|
||||||
|
$mark = static function (int $id) use (&$drawn, $inScope): bool {
|
||||||
|
if (!isset($inScope[$id]) || isset($drawn[$id])) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$drawn[$id] = true;
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
$mark($rootId);
|
||||||
|
|
||||||
|
// ① 祖先链
|
||||||
|
$cur = $rootId;
|
||||||
|
for ($i = 0; $i < 12; $i++) {
|
||||||
|
$next = 0;
|
||||||
|
foreach (self::parentIds($g, $cur) as $p) {
|
||||||
|
if (isset($inScope[(int)$p])) { $next = (int)$p; break; }
|
||||||
|
}
|
||||||
|
if ($next === 0) { break; }
|
||||||
|
$mark($next);
|
||||||
|
$cur = $next;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ③ 根的兄弟姐妹(共享父母)
|
||||||
|
foreach (self::parentIds($g, $rootId) as $p) {
|
||||||
|
foreach (self::childIds($g, (int)$p) as $sib) {
|
||||||
|
$mark((int)$sib);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ② 后代:从根出发逐层往下;④ 每加进一个人,配偶跟着进来
|
||||||
|
$queue = array_keys($drawn);
|
||||||
|
while ($queue) {
|
||||||
|
$id = (int)array_shift($queue);
|
||||||
|
foreach (self::spouseIds($g, $id) as $s) {
|
||||||
|
if ($mark((int)$s)) { $queue[] = (int)$s; }
|
||||||
|
}
|
||||||
|
foreach (self::childIds($g, $id) as $c) {
|
||||||
|
if ($mark((int)$c)) { $queue[] = (int)$c; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 收尾再来一轮配偶(新进来的孩子要带上他们的配偶)
|
||||||
|
foreach (array_keys($drawn) as $id) {
|
||||||
|
foreach (self::spouseIds($g, (int)$id) as $s) {
|
||||||
|
$mark((int)$s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $drawn;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* v15:把「孩子」按**父母所在的户**归堆。
|
* v15:把「孩子」按**父母所在的户**归堆。
|
||||||
*
|
*
|
||||||
@@ -566,7 +709,13 @@ final class Kinship
|
|||||||
return $isKnown ? ($isMale ? '表兄弟' : '表姐妹') : '表亲';
|
return $isKnown ? ($isMale ? '表兄弟' : '表姐妹') : '表亲';
|
||||||
}
|
}
|
||||||
|
|
||||||
return '亲属';
|
// ⚠️ v24(用户 2026-10-02 第 1 条):这里原来兜底返回「亲属」。
|
||||||
|
// 后果是名册里**每个没有血缘关系的人**(同事、朋友、老师…)都被贴上「亲属」——
|
||||||
|
// 人家明明只是个联系人,叫"亲属"是错的,而且人一多满屏都是同一个词,
|
||||||
|
// 看不出任何信息。「亲属」这个词本身也没有定义:关系推导不出来就是没关系,
|
||||||
|
// 硬套一个称呼反而是编造。
|
||||||
|
// 现在改为**返回空串**:没关系就不显示标签。调用方一律用 empty() 判断。
|
||||||
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 配偶的称呼:按「我的性别」定,因为中文里丈夫/妻子是从自己视角说的 */
|
/** 配偶的称呼:按「我的性别」定,因为中文里丈夫/妻子是从自己视角说的 */
|
||||||
|
|||||||
+314
@@ -0,0 +1,314 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* 财务 / 账本(v24 用户第 15 条)
|
||||||
|
*
|
||||||
|
* 用户原话:「增加财务模块,用户可以添加自己的财务状况,例如存款、基金、保险、房子、车子等,
|
||||||
|
* 输入金额,最后给计算出所有的资金总数。这个模块,要先创建一个家庭,或者说,
|
||||||
|
* 创建一个账本,这个账本,可以共享给某个人,共享后,这个人就可以往这个账本加入账目,
|
||||||
|
* 这样,双方都可以看到所有的账目,最后计算出总数,这就是一个家庭的总账目。」
|
||||||
|
*
|
||||||
|
* 所以模型是两层:
|
||||||
|
* **账本(ledger)** —— 一个家庭的总账;可以共享给某个人(可读 / 可写)。
|
||||||
|
* **账目(ledger_entries)** —— 一笔资产/负债:存款、基金、保险、房子、车子…
|
||||||
|
*
|
||||||
|
* 三个设计决定:
|
||||||
|
* ① 金额一律用**分**存(BIGINT),不用浮点。
|
||||||
|
* 钱是最不能容忍 0.1+0.2=0.30000000000000004 的地方。
|
||||||
|
* 对外一律走 amountYuan() 转成元来显示,四舍五入到分。
|
||||||
|
* ② 分清「资产 / 负债」:房子车子是资产,房贷车贷是负债。
|
||||||
|
* 只把资产加总会把"欠着银行两百万"这件事藏起来。净资产 = 资产 − 负债。
|
||||||
|
* ③ 账本为空时**自动建一个默认账本**并把它共享给建号的人,
|
||||||
|
* 否则新用户进财务页一片空白、还不知道要先"创建家庭"。
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App;
|
||||||
|
|
||||||
|
final class Ledger
|
||||||
|
{
|
||||||
|
/** 账目分类:key => [名字, 是资产还是负债, 图标] */
|
||||||
|
public const KINDS = [
|
||||||
|
'deposit' => ['存款', 'asset', '🏦'],
|
||||||
|
'fund' => ['基金', 'asset', '📈'],
|
||||||
|
'stock' => ['股票', 'asset', '📊'],
|
||||||
|
'insurance'=> ['保险', 'asset', '🛡'],
|
||||||
|
'house' => ['房子', 'asset', '🏠'],
|
||||||
|
'car' => ['车子', 'asset', '🚗'],
|
||||||
|
'cash' => ['现金', 'asset', '💵'],
|
||||||
|
'loan' => ['贷款', 'debt', '📉'],
|
||||||
|
'credit' => ['信用卡', 'debt', '💳'],
|
||||||
|
'other' => ['其它', 'asset', '📦'],
|
||||||
|
];
|
||||||
|
|
||||||
|
public const PERM = ['read' => '只读', 'write' => '可写'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 我能看到的账本 = 我建的 + 共享给我的。
|
||||||
|
* 全都带上 my_perm(我在这本账上能做什么),上层不必再查一次。
|
||||||
|
*/
|
||||||
|
public static function mine(int $familyId, int $personId): array
|
||||||
|
{
|
||||||
|
$st = Database::get()->prepare(
|
||||||
|
'SELECT l.*, s.perm AS share_perm,
|
||||||
|
(SELECT COUNT(*) FROM ledger_entries e WHERE e.ledger_id = l.id) AS entry_count
|
||||||
|
FROM ledgers l
|
||||||
|
LEFT JOIN ledger_shares s ON s.ledger_id = l.id AND s.person_id = ?
|
||||||
|
WHERE l.family_id = ? AND (l.owner_id = ? OR s.person_id IS NOT NULL)
|
||||||
|
ORDER BY l.id'
|
||||||
|
);
|
||||||
|
$st->execute([$personId, $familyId, $personId]);
|
||||||
|
$out = [];
|
||||||
|
foreach ($st->fetchAll() as $r) {
|
||||||
|
$r['my_perm'] = (int)$r['owner_id'] === $personId
|
||||||
|
? 'write'
|
||||||
|
: (string)($r['share_perm'] ?? 'read');
|
||||||
|
$out[] = $r;
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function find(int $familyId, int $id, int $personId): ?array
|
||||||
|
{
|
||||||
|
$st = Database::get()->prepare(
|
||||||
|
'SELECT l.*, s.perm AS share_perm FROM ledgers l
|
||||||
|
LEFT JOIN ledger_shares s ON s.ledger_id = l.id AND s.person_id = ?
|
||||||
|
WHERE l.id = ? AND l.family_id = ? AND (l.owner_id = ? OR s.person_id IS NOT NULL)'
|
||||||
|
);
|
||||||
|
$st->execute([$personId, $id, $familyId, $personId]);
|
||||||
|
$r = $st->fetch();
|
||||||
|
if (!$r) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$r['my_perm'] = (int)$r['owner_id'] === $personId ? 'write' : (string)($r['share_perm'] ?? 'read');
|
||||||
|
return $r;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function canWrite(array $ledger): bool
|
||||||
|
{
|
||||||
|
return ($ledger['my_perm'] ?? '') === 'write';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 账本里的全部账目 */
|
||||||
|
public static function entries(int $ledgerId): array
|
||||||
|
{
|
||||||
|
$st = Database::get()->prepare(
|
||||||
|
'SELECT e.*, p.name AS person_name, p.avatar, p.avatar_color,
|
||||||
|
n.nickname AS person_nick
|
||||||
|
FROM ledger_entries e
|
||||||
|
LEFT JOIN persons p ON p.id = e.person_id
|
||||||
|
LEFT JOIN nicknames n ON n.target_id = e.person_id AND n.viewer_id = ?
|
||||||
|
WHERE e.ledger_id = ? ORDER BY e.id'
|
||||||
|
);
|
||||||
|
$st->execute([0, $ledgerId]);
|
||||||
|
return $st->fetchAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 这本账的汇总。
|
||||||
|
* @return array{asset:int,debt:int,net:int,by_kind:array<string,int>,count:int}
|
||||||
|
*/
|
||||||
|
public static function summary(array $entries): array
|
||||||
|
{
|
||||||
|
$asset = 0;
|
||||||
|
$debt = 0;
|
||||||
|
$byKind = [];
|
||||||
|
foreach ($entries as $e) {
|
||||||
|
$amt = (int)($e['amount_cents'] ?? 0);
|
||||||
|
$kind = (string)($e['kind'] ?? 'other');
|
||||||
|
$isDebt = (self::KINDS[$kind][1] ?? 'asset') === 'debt';
|
||||||
|
if ($isDebt) {
|
||||||
|
$debt += $amt;
|
||||||
|
} else {
|
||||||
|
$asset += $amt;
|
||||||
|
}
|
||||||
|
$byKind[$kind] = ($byKind[$kind] ?? 0) + $amt;
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
'asset' => $asset,
|
||||||
|
'debt' => $debt,
|
||||||
|
'net' => $asset - $debt,
|
||||||
|
'by_kind' => $byKind,
|
||||||
|
'count' => count($entries),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- 写 ----------------
|
||||||
|
|
||||||
|
/** @return int 新账本 id */
|
||||||
|
public static function create(int $familyId, int $ownerId, string $name): int
|
||||||
|
{
|
||||||
|
$db = Database::get();
|
||||||
|
$name = trim($name);
|
||||||
|
if ($name === '') {
|
||||||
|
$name = '家庭账本';
|
||||||
|
}
|
||||||
|
$db->prepare('INSERT INTO ledgers (family_id, owner_id, name) VALUES (?,?,?)')
|
||||||
|
->execute([$familyId, $ownerId ?: null, mb_substr($name, 0, 60, 'UTF-8')]);
|
||||||
|
return (int)$db->lastInsertId();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 这个家庭至少有**一本我能看到的**账本;没有就建一本并给我共享。
|
||||||
|
* @return int 账本 id
|
||||||
|
*/
|
||||||
|
public static function ensureDefault(int $familyId, int $personId): int
|
||||||
|
{
|
||||||
|
$mine = self::mine($familyId, $personId);
|
||||||
|
if ($mine) {
|
||||||
|
return (int)$mine[0]['id'];
|
||||||
|
}
|
||||||
|
$id = self::create($familyId, $personId, '家庭账本');
|
||||||
|
return $id; // 建的人就是 owner,天然能写
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function rename(int $familyId, int $id, string $name): void
|
||||||
|
{
|
||||||
|
Database::get()->prepare('UPDATE ledgers SET name = ? WHERE id = ? AND family_id = ?')
|
||||||
|
->execute([mb_substr(trim($name), 0, 60, 'UTF-8'), $id, $familyId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function remove(int $familyId, int $id): void
|
||||||
|
{
|
||||||
|
// 账目与共享行靠外键 ON DELETE CASCADE 一起走
|
||||||
|
Database::get()->prepare('DELETE FROM ledgers WHERE id = ? AND family_id = ?')
|
||||||
|
->execute([$id, $familyId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 共享给某人(重复共享 = 改权限) */
|
||||||
|
public static function setShare(int $ledgerId, int $personId, string $perm = 'read'): void
|
||||||
|
{
|
||||||
|
$perm = isset(self::PERM[$perm]) ? $perm : 'read';
|
||||||
|
Database::get()->prepare(
|
||||||
|
'INSERT INTO ledger_shares (ledger_id, person_id, perm) VALUES (?,?,?)
|
||||||
|
ON DUPLICATE KEY UPDATE perm = VALUES(perm)'
|
||||||
|
)->execute([$ledgerId, $personId, $perm]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function unshare(int $ledgerId, int $personId): void
|
||||||
|
{
|
||||||
|
Database::get()->prepare('DELETE FROM ledger_shares WHERE ledger_id = ? AND person_id = ?')
|
||||||
|
->execute([$ledgerId, $personId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function shares(int $ledgerId): array
|
||||||
|
{
|
||||||
|
$st = Database::get()->prepare(
|
||||||
|
'SELECT s.*, p.name, p.avatar, p.avatar_color
|
||||||
|
FROM ledger_shares s JOIN persons p ON p.id = s.person_id
|
||||||
|
WHERE s.ledger_id = ? ORDER BY s.id'
|
||||||
|
);
|
||||||
|
$st->execute([$ledgerId]);
|
||||||
|
return $st->fetchAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return int 新增账目 id */
|
||||||
|
public static function addEntry(int $ledgerId, array $d, int $byId = 0): int
|
||||||
|
{
|
||||||
|
$db = Database::get();
|
||||||
|
$db->prepare(
|
||||||
|
'INSERT INTO ledger_entries (ledger_id, kind, title, amount_cents, note, person_id, created_by)
|
||||||
|
VALUES (?,?,?,?,?,?,?)'
|
||||||
|
)->execute([
|
||||||
|
$ledgerId,
|
||||||
|
self::kind($d['kind'] ?? ''),
|
||||||
|
mb_substr(trim((string)($d['title'] ?? '')), 0, 120, 'UTF-8'),
|
||||||
|
self::toCents($d['amount'] ?? ''),
|
||||||
|
trim((string)($d['note'] ?? '')) ?: null,
|
||||||
|
(int)($d['person_id'] ?? 0) ?: null,
|
||||||
|
$byId ?: null,
|
||||||
|
]);
|
||||||
|
return (int)$db->lastInsertId();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function updateEntry(int $ledgerId, int $id, array $d): void
|
||||||
|
{
|
||||||
|
Database::get()->prepare(
|
||||||
|
'UPDATE ledger_entries SET kind=?, title=?, amount_cents=?, note=?, person_id=?
|
||||||
|
WHERE id=? AND ledger_id=?'
|
||||||
|
)->execute([
|
||||||
|
self::kind($d['kind'] ?? ''),
|
||||||
|
mb_substr(trim((string)($d['title'] ?? '')), 0, 120, 'UTF-8'),
|
||||||
|
self::toCents($d['amount'] ?? ''),
|
||||||
|
trim((string)($d['note'] ?? '')) ?: null,
|
||||||
|
(int)($d['person_id'] ?? 0) ?: null,
|
||||||
|
$id, $ledgerId,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function removeEntry(int $ledgerId, int $id): void
|
||||||
|
{
|
||||||
|
Database::get()->prepare('DELETE FROM ledger_entries WHERE id = ? AND ledger_id = ?')
|
||||||
|
->execute([$id, $ledgerId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- 钱 ----------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 「元」→「分」。
|
||||||
|
*
|
||||||
|
* ⚠️ 不能用 (int)($yuan * 100):浮点乘法会掉一分(19.99 * 100 = 1998.9999…)。
|
||||||
|
* 一律走字符串:拆出整数部与小数部,各自转整数再拼。
|
||||||
|
*/
|
||||||
|
public static function toCents($yuan): int
|
||||||
|
{
|
||||||
|
$s = trim((string)$yuan);
|
||||||
|
$s = str_replace([',', ',', ' '], '', $s);
|
||||||
|
if ($s === '') {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
$neg = false;
|
||||||
|
if ($s[0] === '-') {
|
||||||
|
$neg = true;
|
||||||
|
$s = substr($s, 1);
|
||||||
|
}
|
||||||
|
if (!preg_match('/^(\d*)(?:\.(\d*))?$/', $s, $m)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
$intPart = $m[1] !== '' ? $m[1] : '0';
|
||||||
|
$dec = $m[2] ?? '';
|
||||||
|
$dec = str_pad(substr($dec, 0, 2), 2, '0'); // 超过两位小数直接截断(不做四舍五入到厘)
|
||||||
|
$cents = (int)$intPart * 100 + (int)$dec;
|
||||||
|
return $neg ? -$cents : $cents;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 「分」→ 显示用的「元」字符串(总是两位小数) */
|
||||||
|
public static function amountYuan($cents): string
|
||||||
|
{
|
||||||
|
$c = (int)$cents;
|
||||||
|
$neg = $c < 0;
|
||||||
|
$c = abs($c);
|
||||||
|
$s = sprintf('%d.%02d', intdiv($c, 100), $c % 100);
|
||||||
|
return ($neg ? '-' : '') . $s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 带千分位、带 ¥ 的显示(列表/汇总用) */
|
||||||
|
public static function money($cents): string
|
||||||
|
{
|
||||||
|
$c = (int)$cents;
|
||||||
|
$neg = $c < 0;
|
||||||
|
$c = abs($c);
|
||||||
|
$s = number_format(intdiv($c, 100)) . '.' . sprintf('%02d', $c % 100);
|
||||||
|
return ($neg ? '-' : '') . '¥' . $s;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function kind(string $k): string
|
||||||
|
{
|
||||||
|
return isset(self::KINDS[$k]) ? $k : 'other';
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function kindWord(string $k): string
|
||||||
|
{
|
||||||
|
return self::KINDS[$k][0] ?? '其它';
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function kindIcon(string $k): string
|
||||||
|
{
|
||||||
|
return self::KINDS[$k][2] ?? '📦';
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function isDebt(string $k): bool
|
||||||
|
{
|
||||||
|
return (self::KINDS[$k][1] ?? 'asset') === 'debt';
|
||||||
|
}
|
||||||
|
}
|
||||||
+28
-2
@@ -41,10 +41,23 @@ final class Module
|
|||||||
'calendar' => ['日程', '#B0763A', 'calendar'],
|
'calendar' => ['日程', '#B0763A', 'calendar'],
|
||||||
'resources' => ['资源', '#5566A8', 'resources'],
|
'resources' => ['资源', '#5566A8', 'resources'],
|
||||||
'tasks' => ['任务', '#A8574F', 'tasks'],
|
'tasks' => ['任务', '#A8574F', 'tasks'],
|
||||||
|
// v24 第 13 条:纪念日(含名册里每个人的生日)
|
||||||
|
'days' => ['纪念日', '#B4633C', 'days'],
|
||||||
|
// v24 第 15 条:财务(账本 + 账目 + 共享)
|
||||||
|
'ledger' => ['财务', '#5E7A3C', 'ledger'],
|
||||||
];
|
];
|
||||||
|
|
||||||
/** 默认出现在导航条上的(没自定义过就用它)。首页永远在,且不能被拖到最后面也不影响。 */
|
/**
|
||||||
public const DEFAULT_NAV = ['home', 'kin', 'people', 'movies', 'trips', 'calendar', 'resources', 'tasks'];
|
* 默认出现在导航条上的(没自定义过就用它)。首页永远在,且不能被拖到最后面也不影响。
|
||||||
|
*
|
||||||
|
* ⚠️ v24(用户 2026-10-02 第 14 条):**家谱默认不上导航条** ——
|
||||||
|
* "毕竟,不需要每天都查看家谱"。家谱模块本身还在(个人设置里能勾回来),
|
||||||
|
* 只是不再占顶栏的位置。
|
||||||
|
*/
|
||||||
|
public const DEFAULT_NAV = ['home', 'people', 'movies', 'trips', 'calendar', 'resources', 'tasks', 'days', 'ledger'];
|
||||||
|
|
||||||
|
/** 已经做过「家谱撤出导航条」这件事的人,记一个标记(只做一次,之后用户勾回来就尊重他) */
|
||||||
|
private const NAV_KIN_OFF_FLAG = 'nav_kin_off_v24';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 主题色的预设色板(个人设置里点一下就换)。
|
* 主题色的预设色板(个人设置里点一下就换)。
|
||||||
@@ -106,6 +119,19 @@ final class Module
|
|||||||
$saved[] = $k;
|
$saved[] = $k;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v24 一次性迁移:老用户存过配置、里面还留着家谱的,帮他撤下来。
|
||||||
|
// ⚠️ 只做一次(记 NAV_KIN_OFF_FLAG)—— 之后他自己在设置里勾回来就该尊重他,
|
||||||
|
// 否则每次请求都删一遍 = "家谱开关"永远失效。
|
||||||
|
if ($personId > 0
|
||||||
|
&& (string)UserSetting::get($personId, self::NAV_KIN_OFF_FLAG, '') === '') {
|
||||||
|
UserSetting::set($personId, self::NAV_KIN_OFF_FLAG, '1');
|
||||||
|
if (in_array('kin', $saved, true)) {
|
||||||
|
$saved = array_values(array_filter($saved, static fn($k) => $k !== 'kin'));
|
||||||
|
UserSetting::set($personId, 'nav_modules',
|
||||||
|
json_encode($saved, JSON_UNESCAPED_UNICODE));
|
||||||
|
}
|
||||||
|
}
|
||||||
// ⚠️ 「新装的模块要自动出现」**不能**用"不在 saved 里就补"来实现 ——
|
// ⚠️ 「新装的模块要自动出现」**不能**用"不在 saved 里就补"来实现 ——
|
||||||
// 那样用户**主动关掉**的模块下一次请求就会被补回来,
|
// 那样用户**主动关掉**的模块下一次请求就会被补回来,
|
||||||
// 「可设置哪些模块出现在导航条」直接失效(实测:关掉观影,刷新它又回来了)。
|
// 「可设置哪些模块出现在导航条」直接失效(实测:关掉观影,刷新它又回来了)。
|
||||||
|
|||||||
+11
-1
@@ -123,7 +123,17 @@ final class Resource
|
|||||||
self::textOrNull($d['content'] ?? ''),
|
self::textOrNull($d['content'] ?? ''),
|
||||||
$createdBy ?: null,
|
$createdBy ?: null,
|
||||||
]);
|
]);
|
||||||
return (int)$db->lastInsertId();
|
$id = (int)$db->lastInsertId();
|
||||||
|
|
||||||
|
// v24(用户 2026-10-02 第 17 条):**我建的、挂在别人名下的资源,我自己也要看得见**。
|
||||||
|
// 以前只有 owner 能看(共享表里不含 owner),于是"我帮孩子录了一张证件"之后,
|
||||||
|
// 这条资源从我的列表里**消失了** —— 我刚建的东西自己却找不到,看着像数据丢了。
|
||||||
|
// 现在建的同时给自己补一条共享(可读写):既能看能改,也不会在"共享给谁"
|
||||||
|
// 的列表里冒出 owner 自己(owner 那栏由 perm='owner' 单独表示)。
|
||||||
|
if ($createdBy > 0 && $createdBy !== $ownerId) {
|
||||||
|
self::setShare($id, $ownerId, $createdBy, 'write');
|
||||||
|
}
|
||||||
|
return $id;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function update(int $familyId, int $id, array $d): void
|
public static function update(int $familyId, int $id, array $d): void
|
||||||
|
|||||||
+227
@@ -119,6 +119,19 @@ CREATE TABLE IF NOT EXISTS `accounts` (
|
|||||||
UNIQUE KEY `uk_account_person` (`person_id`),
|
UNIQUE KEY `uk_account_person` (`person_id`),
|
||||||
CONSTRAINT `fk_accounts_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE
|
CONSTRAINT `fk_accounts_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='登录账号'
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='登录账号'
|
||||||
|
SQL,
|
||||||
|
<<<'SQL'
|
||||||
|
CREATE TABLE IF NOT EXISTS `caldav_credentials` (
|
||||||
|
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`person_id` INT UNSIGNED NOT NULL,
|
||||||
|
`token_hash` CHAR(64) NOT NULL COMMENT 'CalDAV 专用口令的 sha256;口令本身不落库',
|
||||||
|
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
`last_used_at` DATETIME DEFAULT NULL,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uk_cd_person` (`person_id`),
|
||||||
|
CONSTRAINT `fk_cd_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
COMMENT='CalDAV 专用口令(与登录口令分开:客户端会把它长期存着,不能是登录口令)'
|
||||||
SQL,
|
SQL,
|
||||||
<<<'SQL'
|
<<<'SQL'
|
||||||
CREATE TABLE IF NOT EXISTS `relations` (
|
CREATE TABLE IF NOT EXISTS `relations` (
|
||||||
@@ -759,6 +772,179 @@ SQL,
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---- 日程按 CalDAV / iCalendar 对齐(版本 13)----
|
||||||
|
// 用户 2026-10-02 第 5、6 条:起止时刻**精确到秒**,且「日期和时间在一个控件里一起选」。
|
||||||
|
// 原来是 starts_at DATE + start_time TIME 两个列,天然就是"分开的"两件事 ——
|
||||||
|
// 连"跨天又跨时刻"这种最基本的日程都表达不了。现在并成一个 DATETIME。
|
||||||
|
// ⚠️ 先 MODIFY 再回填:MODIFY 之后老数据的 DATE 自动变成 'YYYY-MM-DD 00:00:00',
|
||||||
|
// 紧接着把 start_time 并进去,老日程的时刻才不会一夜之间全变成 00:00。
|
||||||
|
$db->exec("ALTER TABLE `calendar_events` MODIFY COLUMN `starts_at`
|
||||||
|
DATETIME NOT NULL COMMENT '开始时刻(精确到秒;全天日程的时间部分为 00:00:00)'");
|
||||||
|
$db->exec("ALTER TABLE `calendar_events` MODIFY COLUMN `ends_at`
|
||||||
|
DATETIME DEFAULT NULL COMMENT '结束时刻(精确到秒);NULL=与开始同一时刻'");
|
||||||
|
self::tryStep('老日程的时刻并进 starts_at/ends_at', static function () use ($db) {
|
||||||
|
$db->exec("UPDATE `calendar_events`
|
||||||
|
SET `starts_at` = CONCAT(DATE(`starts_at`), ' ', `start_time`)
|
||||||
|
WHERE `start_time` IS NOT NULL AND `all_day` = 0");
|
||||||
|
$db->exec("UPDATE `calendar_events`
|
||||||
|
SET `ends_at` = CONCAT(DATE(`ends_at`), ' ', `end_time`)
|
||||||
|
WHERE `ends_at` IS NOT NULL AND `end_time` IS NOT NULL AND `all_day` = 0");
|
||||||
|
});
|
||||||
|
// 其余字段按 RFC 5545(iCalendar)/ RFC 4791(CalDAV)的 VEVENT 属性定:
|
||||||
|
// UID/DTSTAMP/SEQUENCE 是 CalDAV 同步三件套(没有它们客户端每次都当新日程);
|
||||||
|
// LOCATION / STATUS / TRANSP / RRULE 是用户第 6 条要的地点与忙闲;
|
||||||
|
// reminder_minutes 对应 VALARM 的 TRIGGER(提前多久提醒)。
|
||||||
|
self::addColumn($db, 'calendar_events', 'uid',
|
||||||
|
"ADD COLUMN `uid` VARCHAR(255) DEFAULT NULL COMMENT 'iCalendar UID(RFC 5545):一条日程全球唯一的身份证,CalDAV 与 ics 导入导出全靠它判重' AFTER `calendar_id`");
|
||||||
|
self::addColumn($db, 'calendar_events', 'location',
|
||||||
|
"ADD COLUMN `location` VARCHAR(255) DEFAULT NULL COMMENT '地点(RFC 5545 LOCATION)' AFTER `title`");
|
||||||
|
self::addColumn($db, 'calendar_events', 'reminder_minutes',
|
||||||
|
"ADD COLUMN `reminder_minutes` INT DEFAULT NULL COMMENT '提前多少分钟提醒(对应 VALARM 的 TRIGGER);NULL=不提醒' AFTER `all_day`");
|
||||||
|
self::addColumn($db, 'calendar_events', 'status',
|
||||||
|
"ADD COLUMN `status` ENUM('confirmed','tentative','cancelled') NOT NULL DEFAULT 'confirmed' COMMENT 'RFC 5545 STATUS:确定 / 待定 / 取消' AFTER `reminder_minutes`");
|
||||||
|
self::addColumn($db, 'calendar_events', 'transp',
|
||||||
|
"ADD COLUMN `transp` TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'RFC 5545 TRANSP:0=忙碌(OPAQUE) / 1=空闲(TRANSPARENT)' AFTER `status`");
|
||||||
|
self::addColumn($db, 'calendar_events', 'rrule',
|
||||||
|
"ADD COLUMN `rrule` VARCHAR(255) DEFAULT NULL COMMENT 'RFC 5545 RRULE 重复规则原文(ics 导入导出原样带过,不做解释)' AFTER `transp`");
|
||||||
|
self::addColumn($db, 'calendar_events', 'sequence',
|
||||||
|
"ADD COLUMN `sequence` INT NOT NULL DEFAULT 0 COMMENT 'RFC 5545 SEQUENCE:改一次 +1,CalDAV 客户端据此判断要不要拉新版' AFTER `rrule`");
|
||||||
|
self::addColumn($db, 'calendar_events', 'created_at',
|
||||||
|
"ADD COLUMN `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 'RFC 5545 CREATED' AFTER `created_by`");
|
||||||
|
self::addColumn($db, 'calendar_events', 'updated_at',
|
||||||
|
"ADD COLUMN `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 'RFC 5545 LAST-MODIFIED' AFTER `created_at`");
|
||||||
|
// 老日程补 UID(CalDAV 那边没有 UID 就等于"每次都是新日程",客户端会无限重复)
|
||||||
|
self::tryStep('给老日程补 UID / SEQUENCE', static function () use ($db) {
|
||||||
|
self::backfillCalendarUid($db);
|
||||||
|
});
|
||||||
|
// 每人的 CalDAV 口令(第 7 条:把我的日历本通过 CalDAV 分享出去)。
|
||||||
|
// 存 user_settings,不新建表 —— 与 nav_modules / nav_known 同一套做法。
|
||||||
|
|
||||||
|
// ---- 资源的创建者默认共享(版本 13)----
|
||||||
|
// 历史数据里「我建的、挂在别人名下的资源」没有共享行 → 我在自己的列表里看不到它。
|
||||||
|
// 新建时已经会自动补(Resource::create),这里把老的一次补齐(幂等:只补没有的)。
|
||||||
|
self::tryStep('回填资源创建者的共享权限', static function () use ($db) {
|
||||||
|
self::backfillResourceCreatorShare($db);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- 任务:分类 + 自定义看板(版本 13,用户第 9、10 条)----
|
||||||
|
// 看板(board)与状态(status)是两件事:
|
||||||
|
// status 是这条任务**做到哪一步**(todo / doing / done);
|
||||||
|
// board 是它**属于哪一块**(家务 / 装修 / 旅行准备 …)。
|
||||||
|
// 之前只有 status,于是"想分开看几件事"只能靠给标题加前缀,越用越乱。
|
||||||
|
$db->exec(
|
||||||
|
'CREATE TABLE IF NOT EXISTS `task_boards` (
|
||||||
|
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`family_id` INT UNSIGNED NOT NULL,
|
||||||
|
`name` VARCHAR(80) NOT NULL,
|
||||||
|
`position` INT NOT NULL DEFAULT 100,
|
||||||
|
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `idx_tb_family` (`family_id`, `position`),
|
||||||
|
CONSTRAINT `fk_tb_family` FOREIGN KEY (`family_id`) REFERENCES `families` (`id`) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci'
|
||||||
|
);
|
||||||
|
$db->exec(
|
||||||
|
'CREATE TABLE IF NOT EXISTS `task_categories` (
|
||||||
|
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`family_id` INT UNSIGNED NOT NULL,
|
||||||
|
`name` VARCHAR(80) NOT NULL,
|
||||||
|
`position` INT NOT NULL DEFAULT 100,
|
||||||
|
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `idx_tc_family` (`family_id`, `position`),
|
||||||
|
CONSTRAINT `fk_tc_family` FOREIGN KEY (`family_id`) REFERENCES `families` (`id`) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci'
|
||||||
|
);
|
||||||
|
self::addColumn($db, 'tasks', 'board_id',
|
||||||
|
"ADD COLUMN `board_id` INT UNSIGNED DEFAULT NULL COMMENT '属于哪个看板(用户自建;NULL=默认看板)' AFTER `family_id`");
|
||||||
|
self::addColumn($db, 'tasks', 'category_id',
|
||||||
|
"ADD COLUMN `category_id` INT UNSIGNED DEFAULT NULL COMMENT '属于哪个分类(用户自建;NULL=未分类)' AFTER `board_id`");
|
||||||
|
// 每个家庭至少有一个看板,否则"看板"这个概念在界面上就是空的
|
||||||
|
self::tryStep('给每个家庭补一个默认看板', static function () use ($db) {
|
||||||
|
foreach ($db->query('SELECT id FROM families')->fetchAll() as $fam) {
|
||||||
|
\App\Task::ensureDefaultBoard((int)$fam['id']);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- 纪念日(版本 13,用户第 13 条)----
|
||||||
|
// ⚠️ 名册里每个人的生日**不入这张表** —— 它们是实时从 persons 派生的
|
||||||
|
// (见 Anniversary::birthdays),改了生日纪念日就跟着变。
|
||||||
|
// 这里只存"用户自己加的"纪念日(结婚纪念日、忌日、相识纪念日…)。
|
||||||
|
// month/day 按**该重复方式的月日**存:repeat_type=lunar 时是农历的月日,
|
||||||
|
// 不是公历换算结果(换算只对某一年有效,存了明年就错)。
|
||||||
|
// ⚠️ 用 heredoc(<<<'SQL')而不是手写单引号字符串:这一段里有 ENUM 的
|
||||||
|
// 引号、中文 COMMENT、反引号列名,逐处 `\'` 转义极容易漏一个引号 ——
|
||||||
|
// 实测就漏过(PHP 报 "unexpected identifier VARCHAR",看着像 SQL 语法错,
|
||||||
|
// 其实全是 PHP 字符串没闭合)。heredoc 里什么都不用转义。
|
||||||
|
$db->exec(<<<'SQL'
|
||||||
|
CREATE TABLE IF NOT EXISTS `anniversaries` (
|
||||||
|
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`family_id` INT UNSIGNED NOT NULL,
|
||||||
|
`title` VARCHAR(120) NOT NULL,
|
||||||
|
`repeat_type` ENUM('solar','lunar','once') NOT NULL DEFAULT 'solar'
|
||||||
|
COMMENT '重复方式:solar=每年阳历同月同日 / lunar=每年农历同月同日 / once=只此一次',
|
||||||
|
`month` TINYINT UNSIGNED NOT NULL,
|
||||||
|
`day` TINYINT UNSIGNED NOT NULL,
|
||||||
|
`is_leap` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '农历:该月是否为闰月',
|
||||||
|
`kind` VARCHAR(40) DEFAULT NULL COMMENT '分类:生日 / 结婚纪念日 / 忌日 …',
|
||||||
|
`note` TEXT DEFAULT NULL,
|
||||||
|
`person_id` INT UNSIGNED DEFAULT NULL COMMENT '与谁相关(可空)',
|
||||||
|
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `idx_ann_family` (`family_id`, `month`, `day`),
|
||||||
|
CONSTRAINT `fk_ann_family` FOREIGN KEY (`family_id`) REFERENCES `families` (`id`) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT `fk_ann_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE SET NULL
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
COMMENT='纪念日:用户自建(生日不在此表,由 persons 实时派生)'
|
||||||
|
SQL);
|
||||||
|
|
||||||
|
// ---- 财务 / 账本(版本 13,用户第 15 条)----
|
||||||
|
// 两层:账本(可共享给人的一本总账)+ 账目(存款/基金/保险/房子/车子…)。
|
||||||
|
// ⚠️ 金额一律存**分**(BIGINT),不存浮点:钱是最不能容忍
|
||||||
|
// "0.1+0.2=0.30000000000000004" 的地方(见 Ledger::toCents 的注释)。
|
||||||
|
$db->exec(<<<'SQL'
|
||||||
|
CREATE TABLE IF NOT EXISTS `ledgers` (
|
||||||
|
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`family_id` INT UNSIGNED NOT NULL,
|
||||||
|
`owner_id` INT UNSIGNED DEFAULT NULL COMMENT '创建者',
|
||||||
|
`name` VARCHAR(60) NOT NULL,
|
||||||
|
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `idx_led_family` (`family_id`),
|
||||||
|
CONSTRAINT `fk_led_family` FOREIGN KEY (`family_id`) REFERENCES `families` (`id`) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT `fk_led_owner` FOREIGN KEY (`owner_id`) REFERENCES `persons` (`id`) ON DELETE SET NULL
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='账本:一本家庭总账,可共享给人'
|
||||||
|
SQL);
|
||||||
|
$db->exec(<<<'SQL'
|
||||||
|
CREATE TABLE IF NOT EXISTS `ledger_entries` (
|
||||||
|
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`ledger_id` INT UNSIGNED NOT NULL,
|
||||||
|
`kind` VARCHAR(20) NOT NULL DEFAULT 'other' COMMENT '存款/基金/保险/房子/车子/贷款…',
|
||||||
|
`title` VARCHAR(120) NOT NULL,
|
||||||
|
`amount_cents` BIGINT NOT NULL DEFAULT 0 COMMENT '金额(**分**):正数记绝对值,是资产还是负债由 kind 决定',
|
||||||
|
`note` TEXT DEFAULT NULL,
|
||||||
|
`person_id` INT UNSIGNED DEFAULT NULL COMMENT '挂在谁名下(可空)',
|
||||||
|
`created_by` INT UNSIGNED DEFAULT NULL,
|
||||||
|
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `idx_le_ledger` (`ledger_id`),
|
||||||
|
CONSTRAINT `fk_le_ledger` FOREIGN KEY (`ledger_id`) REFERENCES `ledgers` (`id`) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT `fk_le_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE SET NULL
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='账目:一笔资产或负债'
|
||||||
|
SQL);
|
||||||
|
$db->exec(<<<'SQL'
|
||||||
|
CREATE TABLE IF NOT EXISTS `ledger_shares` (
|
||||||
|
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`ledger_id` INT UNSIGNED NOT NULL,
|
||||||
|
`person_id` INT UNSIGNED NOT NULL,
|
||||||
|
`perm` ENUM('read','write') NOT NULL DEFAULT 'read',
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uk_led_person` (`ledger_id`, `person_id`),
|
||||||
|
CONSTRAINT `fk_ls_ledger` FOREIGN KEY (`ledger_id`) REFERENCES `ledgers` (`id`) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT `fk_ls_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='账本共享'
|
||||||
|
SQL);
|
||||||
|
|
||||||
// 被容错跳过的数据步骤记进设置,系统设置页会把它显出来 ——
|
// 被容错跳过的数据步骤记进设置,系统设置页会把它显出来 ——
|
||||||
// 不然「升级没跑完」这件事只活在日志里,跟没发生一样。
|
// 不然「升级没跑完」这件事只活在日志里,跟没发生一样。
|
||||||
if (self::$stepErrors) {
|
if (self::$stepErrors) {
|
||||||
@@ -771,6 +957,47 @@ SQL,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v24:给老日程补 UID(RFC 5545 要求 VEVENT 必须有 UID)。
|
||||||
|
*
|
||||||
|
* 格式沿用「域名倒写 + 随机」的通行写法:`<random>-<id>@familybook`,
|
||||||
|
* 保证在本系统内唯一即可(跨系统的唯一性靠随机段)。幂等:只补 uid IS NULL 的。
|
||||||
|
*/
|
||||||
|
private static function backfillCalendarUid(\PDO $db): void
|
||||||
|
{
|
||||||
|
$rows = $db->query('SELECT id FROM calendar_events WHERE uid IS NULL OR uid = \'\'')->fetchAll();
|
||||||
|
if (!$rows) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$st = $db->prepare('UPDATE calendar_events SET uid = ? WHERE id = ?');
|
||||||
|
foreach ($rows as $r) {
|
||||||
|
$st->execute([bin2hex(random_bytes(8)) . '-' . (int)$r['id'] . '@familybook', (int)$r['id']]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v24(用户 2026-10-02 第 17 条):给「我建的、挂在别人名下的资源」补一条共享。
|
||||||
|
*
|
||||||
|
* 只补 `created_by` 有值且 ≠ owner 的、**共享表里还没有这一对的**。
|
||||||
|
* 幂等(NOT EXISTS 判过才插),重复跑不插重。
|
||||||
|
*/
|
||||||
|
private static function backfillResourceCreatorShare(\PDO $db): void
|
||||||
|
{
|
||||||
|
if (!self::hasColumn($db, 'resources', 'created_by')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$db->exec(
|
||||||
|
"INSERT INTO resource_shares (resource_id, person_id, perm)
|
||||||
|
SELECT r.id, r.created_by, 'write'
|
||||||
|
FROM resources r
|
||||||
|
WHERE r.created_by IS NOT NULL
|
||||||
|
AND r.created_by <> r.owner_id
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM resource_shares s
|
||||||
|
WHERE s.resource_id = r.id AND s.person_id = r.created_by)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ⚠️ `birthday_lunar = ''` 这种写法**只在本机的 MariaDB 上能跑**:
|
* ⚠️ `birthday_lunar = ''` 这种写法**只在本机的 MariaDB 上能跑**:
|
||||||
* MySQL 严格模式(线上是 8.0 + STRICT_TRANS_TABLES)会把右边的 `''`
|
* MySQL 严格模式(线上是 8.0 + STRICT_TRANS_TABLES)会把右边的 `''`
|
||||||
|
|||||||
+222
-9
@@ -35,6 +35,202 @@ final class Task
|
|||||||
/** 附件的宿主类型 */
|
/** 附件的宿主类型 */
|
||||||
public const ATTACH = 'task';
|
public const ATTACH = 'task';
|
||||||
|
|
||||||
|
// ---------------- 看板与分类(v24 用户第 9、10 条)----------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 这个家庭的看板列表。
|
||||||
|
*
|
||||||
|
* ⚠️ 「看板」与「状态」是两件事:状态是这条任务做到哪一步,看板是它属于哪一块
|
||||||
|
* (家务 / 装修 / 旅行准备)。之前只有状态,想分开看只能给标题加前缀。
|
||||||
|
*/
|
||||||
|
public static function boards(int $familyId): array
|
||||||
|
{
|
||||||
|
$st = Database::get()->prepare(
|
||||||
|
'SELECT b.*, (SELECT COUNT(*) FROM tasks t WHERE t.board_id = b.id AND t.parent_id IS NULL
|
||||||
|
AND t.status <> "done") AS open_count
|
||||||
|
FROM task_boards b WHERE b.family_id = ? ORDER BY b.position, b.id'
|
||||||
|
);
|
||||||
|
$st->execute([$familyId]);
|
||||||
|
return $st->fetchAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function categories(int $familyId): array
|
||||||
|
{
|
||||||
|
$st = Database::get()->prepare(
|
||||||
|
'SELECT c.*, (SELECT COUNT(*) FROM tasks t WHERE t.category_id = c.id) AS used_count
|
||||||
|
FROM task_categories c WHERE c.family_id = ? ORDER BY c.position, c.id'
|
||||||
|
);
|
||||||
|
$st->execute([$familyId]);
|
||||||
|
return $st->fetchAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function boardName(int $familyId, ?int $boardId): string
|
||||||
|
{
|
||||||
|
if (!$boardId) {
|
||||||
|
return '默认看板';
|
||||||
|
}
|
||||||
|
$st = Database::get()->prepare('SELECT name FROM task_boards WHERE id = ? AND family_id = ?');
|
||||||
|
$st->execute([$boardId, $familyId]);
|
||||||
|
$v = $st->fetchColumn();
|
||||||
|
return $v === false ? '' : (string)$v;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 这个家庭至少有一个看板(升级时给老家庭补,新家庭在建号时补)。
|
||||||
|
* @return int 默认看板的 id
|
||||||
|
*/
|
||||||
|
public static function ensureDefaultBoard(int $familyId): int
|
||||||
|
{
|
||||||
|
$db = Database::get();
|
||||||
|
$st = $db->prepare('SELECT id FROM task_boards WHERE family_id = ? ORDER BY position, id LIMIT 1');
|
||||||
|
$st->execute([$familyId]);
|
||||||
|
$id = (int)$st->fetchColumn();
|
||||||
|
if ($id > 0) {
|
||||||
|
return $id;
|
||||||
|
}
|
||||||
|
$db->prepare('INSERT INTO task_boards (family_id, name, position) VALUES (?,?,?)')
|
||||||
|
->execute([$familyId, '日常', 100]);
|
||||||
|
return (int)$db->lastInsertId();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return int 新建看板的 id */
|
||||||
|
public static function createBoard(int $familyId, string $name): int
|
||||||
|
{
|
||||||
|
$db = Database::get();
|
||||||
|
$db->prepare('INSERT INTO task_boards (family_id, name, position) VALUES (?,?,?)')
|
||||||
|
->execute([$familyId, self::shortName($name), self::nextBoardPosition($familyId)]);
|
||||||
|
return (int)$db->lastInsertId();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function renameBoard(int $familyId, int $id, string $name): void
|
||||||
|
{
|
||||||
|
Database::get()->prepare('UPDATE task_boards SET name = ? WHERE id = ? AND family_id = ?')
|
||||||
|
->execute([self::shortName($name), $id, $familyId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删看板:里头的任务**不删**,挪到默认看板上(删看板 ≠ 删任务,这个代价太突然)。
|
||||||
|
*/
|
||||||
|
public static function deleteBoard(int $familyId, int $id): void
|
||||||
|
{
|
||||||
|
$db = Database::get();
|
||||||
|
$st = $db->prepare('SELECT COUNT(*) FROM task_boards WHERE family_id = ?');
|
||||||
|
$st->execute([$familyId]);
|
||||||
|
if ((int)$st->fetchColumn() <= 1) {
|
||||||
|
return; // 最后一个看板不许删:删了界面上就没处放任务
|
||||||
|
}
|
||||||
|
$def = self::ensureDefaultBoard($familyId);
|
||||||
|
$db->prepare('UPDATE tasks SET board_id = ? WHERE board_id = ? AND family_id = ?')
|
||||||
|
->execute([$def === $id ? null : $def, $id, $familyId]);
|
||||||
|
$db->prepare('DELETE FROM task_boards WHERE id = ? AND family_id = ?')->execute([$id, $familyId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return int 新建分类的 id */
|
||||||
|
public static function createCategory(int $familyId, string $name): int
|
||||||
|
{
|
||||||
|
$db = Database::get();
|
||||||
|
$db->prepare('INSERT INTO task_categories (family_id, name, position) VALUES (?,?,?)')
|
||||||
|
->execute([$familyId, self::shortName($name), self::nextCategoryPosition($familyId)]);
|
||||||
|
return (int)$db->lastInsertId();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function renameCategory(int $familyId, int $id, string $name): void
|
||||||
|
{
|
||||||
|
Database::get()->prepare('UPDATE task_categories SET name = ? WHERE id = ? AND family_id = ?')
|
||||||
|
->execute([self::shortName($name), $id, $familyId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删分类:用过的任务变成「未分类」,任务本身不动 */
|
||||||
|
public static function deleteCategory(int $familyId, int $id): void
|
||||||
|
{
|
||||||
|
$db = Database::get();
|
||||||
|
$db->prepare('UPDATE tasks SET category_id = NULL WHERE category_id = ? AND family_id = ?')
|
||||||
|
->execute([$id, $familyId]);
|
||||||
|
$db->prepare('DELETE FROM task_categories WHERE id = ? AND family_id = ?')->execute([$id, $familyId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 已完成的任务(v24 第 10 条:从看板上消失,另外一处按分类查看/搜索)。
|
||||||
|
*
|
||||||
|
* @param int $categoryId 0=全部分类
|
||||||
|
* @param string $keyword 标题里含这个串(空=不限)
|
||||||
|
*/
|
||||||
|
public static function doneList(int $familyId, int $personId, int $categoryId = 0, string $keyword = ''): array
|
||||||
|
{
|
||||||
|
$kw = trim($keyword);
|
||||||
|
$sql = 'SELECT t.*, c.name AS category_name,
|
||||||
|
a.name AS assignee_name, a.avatar, a.avatar_color,
|
||||||
|
na.nickname AS assignee_nick
|
||||||
|
FROM tasks t
|
||||||
|
LEFT JOIN task_categories c ON c.id = t.category_id
|
||||||
|
LEFT JOIN persons a ON a.id = t.assignee_id
|
||||||
|
LEFT JOIN nicknames na ON na.target_id = t.assignee_id AND na.viewer_id = ?
|
||||||
|
LEFT JOIN task_shares s ON s.task_id = t.id AND s.person_id = ?
|
||||||
|
WHERE t.family_id = ? AND t.status = "done" AND t.parent_id IS NULL
|
||||||
|
AND (t.creator_id = ? OR t.assignee_id = ? OR s.person_id IS NOT NULL)';
|
||||||
|
$args = [$personId, $personId, $familyId, $personId, $personId];
|
||||||
|
if ($categoryId > 0) {
|
||||||
|
$sql .= ' AND t.category_id = ?';
|
||||||
|
$args[] = $categoryId;
|
||||||
|
}
|
||||||
|
if ($kw !== '') {
|
||||||
|
$sql .= ' AND t.title LIKE ?';
|
||||||
|
$args[] = '%' . $kw . '%';
|
||||||
|
}
|
||||||
|
$sql .= ' ORDER BY t.done_at DESC, t.id DESC';
|
||||||
|
$st = Database::get()->prepare($sql);
|
||||||
|
$st->execute($args);
|
||||||
|
return $st->fetchAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 看板 id 归一化:0 / 不存在的 id → 默认看板,并且**必须属于这个家庭**
|
||||||
|
* (不校验的话,改一下表单就能把任务挂到别人家的看板上)。
|
||||||
|
* @return int|null NULL 表示"用默认看板"落在库里也存 NULL,读的时候 COALESCE 回来
|
||||||
|
*/
|
||||||
|
private static function boardIdOf(int $familyId, int $id): ?int
|
||||||
|
{
|
||||||
|
if ($id <= 0) {
|
||||||
|
return self::ensureDefaultBoard($familyId);
|
||||||
|
}
|
||||||
|
$st = Database::get()->prepare('SELECT id FROM task_boards WHERE id = ? AND family_id = ?');
|
||||||
|
$st->execute([$id, $familyId]);
|
||||||
|
$v = $st->fetchColumn();
|
||||||
|
return $v === false ? null : (int)$v;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 分类 id 归一化:0 = 未分类(NULL);不属于这个家庭的一律不认 */
|
||||||
|
private static function categoryIdOf(int $familyId, int $id): ?int
|
||||||
|
{
|
||||||
|
if ($id <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$st = Database::get()->prepare('SELECT id FROM task_categories WHERE id = ? AND family_id = ?');
|
||||||
|
$st->execute([$id, $familyId]);
|
||||||
|
$v = $st->fetchColumn();
|
||||||
|
return $v === false ? null : (int)$v;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function shortName(string $n): string
|
||||||
|
{
|
||||||
|
$n = trim(preg_replace('/\s+/u', ' ', $n ?? ''));
|
||||||
|
return mb_substr($n, 0, 40, 'UTF-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function nextBoardPosition(int $familyId): int
|
||||||
|
{
|
||||||
|
$st = Database::get()->prepare('SELECT COALESCE(MAX(position),0)+10 FROM task_boards WHERE family_id = ?');
|
||||||
|
$st->execute([$familyId]);
|
||||||
|
return (int)$st->fetchColumn();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function nextCategoryPosition(int $familyId): int
|
||||||
|
{
|
||||||
|
$st = Database::get()->prepare('SELECT COALESCE(MAX(position),0)+10 FROM task_categories WHERE family_id = ?');
|
||||||
|
$st->execute([$familyId]);
|
||||||
|
return (int)$st->fetchColumn();
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------- 读 ----------------
|
// ---------------- 读 ----------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -43,24 +239,33 @@ final class Task
|
|||||||
* 一次查出来,内存里按 status 分三堆(看板要的就是这三堆)。
|
* 一次查出来,内存里按 status 分三堆(看板要的就是这三堆)。
|
||||||
* ⚠️ 子任务**不单独成列**:它们是挂在父任务下面的,看板里只出现父任务。
|
* ⚠️ 子任务**不单独成列**:它们是挂在父任务下面的,看板里只出现父任务。
|
||||||
*/
|
*/
|
||||||
public static function board(int $familyId, int $personId): array
|
public static function board(int $familyId, int $personId, int $boardId = 0): array
|
||||||
{
|
{
|
||||||
|
// ⚠️ v24 第 10 条:**已完成不出现在看板里**(用户原话「直接消失」)。
|
||||||
|
// 所以这里根本不查 status='done';它们改由 doneList() 单独呈现。
|
||||||
|
// 看板上仍然只有 todo / doing 两列 —— 第三列没了,才是"已完成消失"。
|
||||||
|
// 老任务的 board_id 是 NULL → 一律算在**默认看板**上(COALESCE 到默认看板 id)
|
||||||
|
$defId = self::ensureDefaultBoard($familyId);
|
||||||
|
$boardId = $boardId > 0 ? $boardId : $defId;
|
||||||
$st = Database::get()->prepare(
|
$st = Database::get()->prepare(
|
||||||
'SELECT t.*, a.name AS assignee_name, a.avatar, a.avatar_color,
|
'SELECT t.*, a.name AS assignee_name, a.avatar, a.avatar_color,
|
||||||
na.nickname AS assignee_nick,
|
na.nickname AS assignee_nick,
|
||||||
(SELECT COUNT(*) FROM tasks c WHERE c.parent_id = t.id) AS child_total,
|
c.name AS category_name,
|
||||||
(SELECT COUNT(*) FROM tasks c WHERE c.parent_id = t.id AND c.status = "done") AS child_done,
|
(SELECT COUNT(*) FROM tasks k WHERE k.parent_id = t.id) AS child_total,
|
||||||
|
(SELECT COUNT(*) FROM tasks k WHERE k.parent_id = t.id AND k.status = "done") AS child_done,
|
||||||
(SELECT COUNT(*) FROM task_comments m WHERE m.task_id = t.id) AS comment_count,
|
(SELECT COUNT(*) FROM task_comments m WHERE m.task_id = t.id) AS comment_count,
|
||||||
(SELECT COUNT(*) FROM attachments f WHERE f.target_type = "task" AND f.target_id = t.id) AS attach_count
|
(SELECT COUNT(*) FROM attachments f WHERE f.target_type = "task" AND f.target_id = t.id) AS attach_count
|
||||||
FROM tasks t
|
FROM tasks t
|
||||||
LEFT JOIN persons a ON a.id = t.assignee_id
|
LEFT JOIN persons a ON a.id = t.assignee_id
|
||||||
LEFT JOIN nicknames na ON na.target_id = t.assignee_id AND na.viewer_id = ?
|
LEFT JOIN nicknames na ON na.target_id = t.assignee_id AND na.viewer_id = ?
|
||||||
LEFT JOIN task_shares s ON s.task_id = t.id AND s.person_id = ?
|
LEFT JOIN task_shares s ON s.task_id = t.id AND s.person_id = ?
|
||||||
WHERE t.family_id = ? AND t.parent_id IS NULL
|
LEFT JOIN task_categories c ON c.id = t.category_id
|
||||||
|
WHERE t.family_id = ? AND t.parent_id IS NULL AND t.status <> "done"
|
||||||
|
AND COALESCE(t.board_id, ?) = ?
|
||||||
AND (t.creator_id = ? OR t.assignee_id = ? OR s.person_id IS NOT NULL)
|
AND (t.creator_id = ? OR t.assignee_id = ? OR s.person_id IS NOT NULL)
|
||||||
ORDER BY t.position, t.id'
|
ORDER BY t.position, t.id'
|
||||||
);
|
);
|
||||||
$st->execute([$personId, $personId, $familyId, $personId, $personId]);
|
$st->execute([$personId, $personId, $familyId, $defId, $boardId, $personId, $personId]);
|
||||||
$rows = $st->fetchAll();
|
$rows = $st->fetchAll();
|
||||||
|
|
||||||
// 一次把全部子任务取出来挂上去(N+1 会让看板在几十条时明显变慢)
|
// 一次把全部子任务取出来挂上去(N+1 会让看板在几十条时明显变慢)
|
||||||
@@ -70,6 +275,8 @@ final class Task
|
|||||||
}
|
}
|
||||||
unset($r);
|
unset($r);
|
||||||
|
|
||||||
|
// ⚠️ 只有 todo / doing 两列:done 已经从看板上撤走了(v24 第 10 条)。
|
||||||
|
// 保留 'done' 这个键(恒空)只为兼容个别直接按 key 取的老调用。
|
||||||
$out = ['todo' => [], 'doing' => [], 'done' => []];
|
$out = ['todo' => [], 'doing' => [], 'done' => []];
|
||||||
foreach ($rows as $r) {
|
foreach ($rows as $r) {
|
||||||
$k = (string)$r['status'];
|
$k = (string)$r['status'];
|
||||||
@@ -216,11 +423,13 @@ final class Task
|
|||||||
{
|
{
|
||||||
$db = Database::get();
|
$db = Database::get();
|
||||||
$db->prepare(
|
$db->prepare(
|
||||||
'INSERT INTO tasks (family_id, parent_id, title, note, status, due_at, position,
|
'INSERT INTO tasks (family_id, board_id, category_id, parent_id, title, note, status, due_at,
|
||||||
creator_id, assignee_id, repeat_rule)
|
position, creator_id, assignee_id, repeat_rule)
|
||||||
VALUES (?,?,?,?,?,?,?,?,?,?)'
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)'
|
||||||
)->execute([
|
)->execute([
|
||||||
$familyId,
|
$familyId,
|
||||||
|
self::boardIdOf($familyId, (int)($d['board_id'] ?? 0)),
|
||||||
|
self::categoryIdOf($familyId, (int)($d['category_id'] ?? 0)),
|
||||||
self::parentId($familyId, (int)($d['parent_id'] ?? 0)),
|
self::parentId($familyId, (int)($d['parent_id'] ?? 0)),
|
||||||
self::title($d['title'] ?? ''),
|
self::title($d['title'] ?? ''),
|
||||||
self::textOrNull($d['note'] ?? ''),
|
self::textOrNull($d['note'] ?? ''),
|
||||||
@@ -247,7 +456,7 @@ final class Task
|
|||||||
$status = self::status($d['status'] ?? '');
|
$status = self::status($d['status'] ?? '');
|
||||||
$db->prepare(
|
$db->prepare(
|
||||||
'UPDATE tasks SET title=?, note=?, status=?, due_at=?, assignee_id=?, repeat_rule=?,
|
'UPDATE tasks SET title=?, note=?, status=?, due_at=?, assignee_id=?, repeat_rule=?,
|
||||||
parent_id=? WHERE id=? AND family_id=?'
|
parent_id=?, board_id=?, category_id=? WHERE id=? AND family_id=?'
|
||||||
)->execute([
|
)->execute([
|
||||||
self::title($d['title'] ?? ''),
|
self::title($d['title'] ?? ''),
|
||||||
self::textOrNull($d['note'] ?? ''),
|
self::textOrNull($d['note'] ?? ''),
|
||||||
@@ -256,6 +465,10 @@ final class Task
|
|||||||
(int)($d['assignee_id'] ?? 0) ?: null,
|
(int)($d['assignee_id'] ?? 0) ?: null,
|
||||||
self::repeat($d['repeat_rule'] ?? ''),
|
self::repeat($d['repeat_rule'] ?? ''),
|
||||||
$pid,
|
$pid,
|
||||||
|
array_key_exists('board_id', $d) ? self::boardIdOf($familyId, (int)$d['board_id']) : $old['board_id'],
|
||||||
|
array_key_exists('category_id', $d)
|
||||||
|
? self::categoryIdOf($familyId, (int)$d['category_id'])
|
||||||
|
: $old['category_id'],
|
||||||
$id, $familyId,
|
$id, $familyId,
|
||||||
]);
|
]);
|
||||||
if ($status === 'done' && (string)$old['status'] !== 'done') {
|
if ($status === 'done' && (string)$old['status'] !== 'done') {
|
||||||
|
|||||||
+311
-30
@@ -521,11 +521,28 @@ textarea.input { min-height: 76px; resize: vertical; }
|
|||||||
.kin-card:hover {
|
.kin-card:hover {
|
||||||
box-shadow: var(--shadow-l); border-color: var(--brand-2); text-decoration: none;
|
box-shadow: var(--shadow-l); border-color: var(--brand-2); text-decoration: none;
|
||||||
}
|
}
|
||||||
|
/* ⚠️ v24 第 12 条:头像是**方形**、不裁切。
|
||||||
|
卡片高度从 120 加到 136,把这块从 70 放大到 74×74 的正方形;
|
||||||
|
object-fit 用 contain(不是 cover)—— cover 会把非正方形的头像切掉边,
|
||||||
|
用户要的正是"所有人的头像都是方形的,别截断"。 */
|
||||||
|
.kin-card {
|
||||||
|
width: 148px; height: 138px;
|
||||||
|
}
|
||||||
.kin-card .kin-ph {
|
.kin-card .kin-ph {
|
||||||
width: 100%; height: 70px; border-radius: 7px; overflow: hidden;
|
width: 74px; height: 74px; border-radius: 6px; overflow: hidden;
|
||||||
background: #F1E7D6; flex: 0 0 auto;
|
background: #F1E7D6; flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
.kin-card .kin-ph img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
.kin-card .kin-ph img {
|
||||||
|
width: 100%; height: 100%; object-fit: contain; display: block;
|
||||||
|
}
|
||||||
|
/* 「⇄ 姻亲」:卡片右下角的小按钮(这一侧的人没画在本图上时才有) */
|
||||||
|
.kin-card .kin-branch {
|
||||||
|
position: absolute; right: 3px; bottom: 3px;
|
||||||
|
font-size: 10px; line-height: 1; padding: 3px 5px; border-radius: 999px;
|
||||||
|
background: var(--brand); color: #fff; cursor: pointer;
|
||||||
|
box-shadow: 0 1px 3px rgba(43, 38, 32, .25);
|
||||||
|
}
|
||||||
|
.kin-card .kin-branch:hover { background: var(--brand-ink); }
|
||||||
.kin-card .kin-nm {
|
.kin-card .kin-nm {
|
||||||
font-size: 12.5px; font-weight: 600; line-height: 1.25; max-width: 100%;
|
font-size: 12.5px; font-weight: 600; line-height: 1.25; max-width: 100%;
|
||||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||||
@@ -1148,6 +1165,13 @@ textarea.input { min-height: 76px; resize: vertical; }
|
|||||||
.lb-btn:hover { background: rgba(255, 255, 255, .26); }
|
.lb-btn:hover { background: rgba(255, 255, 255, .26); }
|
||||||
.lb-btn[hidden] { display: none; }
|
.lb-btn[hidden] { display: none; }
|
||||||
.lb-close { top: 14px; right: 16px; width: 40px; height: 40px; border-radius: 50%; font-size: 18px; }
|
.lb-close { top: 14px; right: 16px; width: 40px; height: 40px; border-radius: 50%; font-size: 18px; }
|
||||||
|
/* 下载按钮在关闭按钮左边,同高、同一套视觉(v24 第 3 条:预览层右上角要有下载) */
|
||||||
|
.lb-dl {
|
||||||
|
top: 14px; right: 66px; height: 40px; padding: 0 16px; border-radius: 20px;
|
||||||
|
display: inline-flex; align-items: center; font-size: 13px; font-weight: 600;
|
||||||
|
background: #fff; color: #1a1a1a; text-decoration: none;
|
||||||
|
}
|
||||||
|
.lb-dl:hover { background: #eee; }
|
||||||
.lb-nav { top: 50%; transform: translateY(-50%); width: 48px; height: 48px; border-radius: 50%; font-size: 28px; padding-bottom: 4px; }
|
.lb-nav { top: 50%; transform: translateY(-50%); width: 48px; height: 48px; border-radius: 50%; font-size: 28px; padding-bottom: 4px; }
|
||||||
.lb-prev { left: 14px; }
|
.lb-prev { left: 14px; }
|
||||||
.lb-next { right: 14px; }
|
.lb-next { right: 14px; }
|
||||||
@@ -1205,54 +1229,217 @@ textarea.input { min-height: 76px; resize: vertical; }
|
|||||||
.stars-disp { display: inline-flex; gap: 1px; font-size: 17px; line-height: 1; vertical-align: -2px; }
|
.stars-disp { display: inline-flex; gap: 1px; font-size: 17px; line-height: 1; vertical-align: -2px; }
|
||||||
.stars-disp .d-on { color: #f5b301; }
|
.stars-disp .d-on { color: #f5b301; }
|
||||||
.stars-disp .d-off { color: #d3d7de; }
|
.stars-disp .d-off { color: #d3d7de; }
|
||||||
/* ---- 同行观影人选择(v10):chips + 弹窗 + 拼音首字母联想(v11 起带头像) ---- */
|
/* ---- 财务 / 账本(v24 第 15 条) ---- */
|
||||||
.att-chips { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; }
|
/* 金额列右对齐 + 等宽数字(表格里数字不齐会很难扫) */
|
||||||
.att-chips:empty { display: none; }
|
.table .num, .table th.num { text-align: right; font-variant-numeric: tabular-nums; }
|
||||||
.att-chip {
|
.table tfoot th { border-top: 2px solid var(--line); border-bottom: 0; color: var(--ink); }
|
||||||
|
.led-sum-head { display: flex; flex-wrap: wrap; align-items: baseline; gap: 10px; }
|
||||||
|
.led-total { display: flex; flex-wrap: wrap; gap: 18px; margin: 12px 0 4px; }
|
||||||
|
.led-total-item { display: flex; flex-direction: column; gap: 2px; }
|
||||||
|
.led-total-lb { font-size: 12px; color: var(--muted); }
|
||||||
|
.led-total-num {
|
||||||
|
font-size: 22px; font-weight: 700; font-variant-numeric: tabular-nums;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
.led-total-item.led-net .led-total-num { color: var(--brand); }
|
||||||
|
/* 数字颜色:**收入/资产用红、负债用绿**(与 A 股涨红跌绿同一套约定,不要反) */
|
||||||
|
.led-asset { color: #B0342C; }
|
||||||
|
.led-debt { color: #1F7A4D; }
|
||||||
|
.led-kinds { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; }
|
||||||
|
.led-kind-chip {
|
||||||
|
display: inline-flex; align-items: center; gap: 5px;
|
||||||
|
padding: 3px 9px; border-radius: 999px; font-size: 12px;
|
||||||
|
background: var(--line-2); color: var(--ink); white-space: nowrap;
|
||||||
|
}
|
||||||
|
.led-kind-chip b { font-variant-numeric: tabular-nums; }
|
||||||
|
.led-kind-chip.is-debt { background: #E7F2EC; }
|
||||||
|
.led-shares { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-bottom: 10px; }
|
||||||
|
.led-share-chip {
|
||||||
|
display: inline-flex; align-items: center; gap: 6px;
|
||||||
|
padding: 3px 8px; border: 1px solid var(--line); border-radius: 999px;
|
||||||
|
}
|
||||||
|
.led-ops { display: flex; flex-wrap: wrap; align-items: flex-start; gap: 12px; }
|
||||||
|
.led-ops .inline-form { flex-wrap: wrap; align-items: center; }
|
||||||
|
.led-ops .input { min-width: 140px; }
|
||||||
|
.led-edit summary { cursor: pointer; }
|
||||||
|
.led-edit .node-form { min-width: min(520px, 86vw); }
|
||||||
|
/* ---- 纪念日(v24 第 13 条) ---- */
|
||||||
|
.ann-up { list-style: none; margin: 0; padding: 0; }
|
||||||
|
.ann-up-item {
|
||||||
|
display: flex; flex-wrap: wrap; align-items: baseline; gap: 8px;
|
||||||
|
padding: 8px 2px; border-bottom: 1px solid var(--line-2); font-size: 14px;
|
||||||
|
}
|
||||||
|
.ann-up-item:last-child { border-bottom: 0; }
|
||||||
|
.ann-days {
|
||||||
|
flex: 0 0 auto; min-width: 62px; font-weight: 600; color: var(--brand);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.ann-date { flex: 0 0 auto; color: var(--muted); font-variant-numeric: tabular-nums; }
|
||||||
|
.ann-title { flex: 1 1 auto; color: var(--ink); min-width: 0; }
|
||||||
|
.chk-inline { display: flex; align-items: center; gap: 6px; font-size: 14px; }
|
||||||
|
/* ---- 任务设置(v24 第 9、10 条):看板/分类的增删改列表 ---- */
|
||||||
|
.kb-boards { margin-bottom: 12px; }
|
||||||
|
.set-list { display: flex; flex-direction: column; gap: 8px; margin: 4px 0 0; }
|
||||||
|
/* 一行 = 一个「改名」表单 + 一个「删除」表单,靠 flex 排成一条 */
|
||||||
|
.set-row { flex: 1 1 auto; display: flex; align-items: center; gap: 8px; margin: 0; }
|
||||||
|
.set-row .input { flex: 1 1 auto; min-width: 0; }
|
||||||
|
.set-tail { flex: 0 0 auto; }
|
||||||
|
/* 「已完成」归档列表 */
|
||||||
|
.done-list { list-style: none; margin: 0; padding: 0; }
|
||||||
|
.done-item {
|
||||||
|
display: flex; flex-wrap: wrap; align-items: center; gap: 8px;
|
||||||
|
padding: 9px 2px; border-bottom: 1px solid var(--line-2); font-size: 14px;
|
||||||
|
}
|
||||||
|
.done-item:last-child { border-bottom: 0; }
|
||||||
|
.done-title { color: var(--ink); text-decoration: none; }
|
||||||
|
.done-title:hover { color: var(--brand); text-decoration: underline; }
|
||||||
|
/* ---- 附件缩略图墙 + 全局预览(v24 第 3 条) ----
|
||||||
|
规则:图片/视频给缩略图,PDF/Word/Excel 给**同尺寸**图标;
|
||||||
|
可预览的点开在本页这一层里看,不可预览的点了直接下载。 */
|
||||||
|
.att-wall {
|
||||||
|
display: flex; flex-wrap: wrap; gap: 12px; margin: 8px 0 2px;
|
||||||
|
}
|
||||||
|
.att-cell { width: 132px; }
|
||||||
|
/* ⚠️ 缩略图与文档图标**必须是同一个盒子**:用户要的是"图标跟预览图一样大小",
|
||||||
|
所以尺寸只在这里写一次,图标靠 flex 撑满,不另给一套尺寸。 */
|
||||||
|
.att-thumb {
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
width: 132px; height: 96px; border-radius: 8px; overflow: hidden;
|
||||||
|
border: 1px solid var(--line); background: var(--line-2); position: relative;
|
||||||
|
}
|
||||||
|
.att-thumb:hover { border-color: var(--brand); }
|
||||||
|
.att-thumb img, .att-thumb video {
|
||||||
|
width: 100%; height: 100%; object-fit: cover; display: block;
|
||||||
|
}
|
||||||
|
.att-thumb video { object-fit: cover; pointer-events: none; }
|
||||||
|
.att-play {
|
||||||
|
position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
|
||||||
|
color: #fff; font-size: 22px; text-shadow: 0 2px 8px rgba(0, 0, 0, .6); pointer-events: none;
|
||||||
|
}
|
||||||
|
.att-doc {
|
||||||
|
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||||
|
gap: 4px; width: 100%; height: 100%;
|
||||||
|
font-size: 12px; font-weight: 700; letter-spacing: .5px; color: #fff;
|
||||||
|
}
|
||||||
|
.att-doc::before {
|
||||||
|
content: ""; width: 34px; height: 34px; border-radius: 6px; background: rgba(255, 255, 255, .22);
|
||||||
|
}
|
||||||
|
.att-doc-pdf { background: #B0342C; }
|
||||||
|
.att-doc-doc { background: #2C5AA0; }
|
||||||
|
.att-doc-xls { background: #1F7A4D; }
|
||||||
|
.att-doc-ppt { background: #B4622A; }
|
||||||
|
.att-doc-text { background: #5F6672; }
|
||||||
|
.att-doc-file { background: #5F6672; }
|
||||||
|
.att-meta {
|
||||||
|
display: flex; flex-wrap: wrap; align-items: baseline; gap: 2px 6px;
|
||||||
|
margin-top: 4px; font-size: 12px; color: var(--muted);
|
||||||
|
}
|
||||||
|
.att-fname {
|
||||||
|
flex: 1 1 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
.att-fsize { flex: 0 0 auto; }
|
||||||
|
.att-meta form { display: inline; margin: 0; }
|
||||||
|
/* ---- 全局预览层 ---- */
|
||||||
|
.pv {
|
||||||
|
position: fixed; inset: 0; z-index: 320; display: flex; flex-direction: column;
|
||||||
|
background: rgba(0, 0, 0, .9); overscroll-behavior: contain;
|
||||||
|
}
|
||||||
|
/* ⚠️ .pv 自己声明了 display:flex,必须补这条,否则 preview.js 里的
|
||||||
|
box.hidden = true 藏不住它(与 .pp-item 同一个坑)。 */
|
||||||
|
.pv[hidden] { display: none; }
|
||||||
|
.pv-head {
|
||||||
|
flex: 0 0 auto; display: flex; align-items: center; gap: 10px;
|
||||||
|
padding: 12px 16px; color: #fff;
|
||||||
|
}
|
||||||
|
.pv-name {
|
||||||
|
min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.pv-meta { flex: 0 0 auto; font-size: 12px; opacity: .7; }
|
||||||
|
/* 下载按钮恒在右上角(用户第 3 条点名要求) */
|
||||||
|
.pv-dl {
|
||||||
|
margin-left: auto; flex: 0 0 auto; padding: 6px 14px; border-radius: 8px;
|
||||||
|
background: #fff; color: #1a1a1a; font-size: 13px; font-weight: 600; text-decoration: none;
|
||||||
|
}
|
||||||
|
.pv-dl:hover { background: #eee; }
|
||||||
|
.pv-close {
|
||||||
|
flex: 0 0 auto; border: 0; background: rgba(255, 255, 255, .16); color: #fff;
|
||||||
|
width: 34px; height: 34px; border-radius: 50%; font-size: 16px; cursor: pointer;
|
||||||
|
}
|
||||||
|
.pv-close:hover { background: rgba(255, 255, 255, .28); }
|
||||||
|
.pv-stage {
|
||||||
|
flex: 1 1 auto; min-height: 0; display: flex; align-items: center; justify-content: center;
|
||||||
|
padding: 0 16px 16px;
|
||||||
|
}
|
||||||
|
.pv-img {
|
||||||
|
max-width: 92vw; max-height: 100%; width: auto; height: auto;
|
||||||
|
object-fit: contain; border-radius: 8px;
|
||||||
|
}
|
||||||
|
.pv-video { max-width: 92vw; max-height: 100%; width: auto; height: auto; background: #000; }
|
||||||
|
.pv-pdf {
|
||||||
|
width: 92vw; height: 100%; border: 0; border-radius: 8px; background: #fff;
|
||||||
|
}
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.att-cell, .att-thumb { width: 108px; }
|
||||||
|
.att-thumb { height: 80px; }
|
||||||
|
}
|
||||||
|
/* ---- 全局人员选择器(v24 第 11 条):chips + 弹窗 + 拼音首字母联想 ----
|
||||||
|
⚠️ .att-* 是这套组件早年在观影页的版本,已由 .pp-* 取代;
|
||||||
|
保留只为历史样式,新页面一律用 _person_picker.php。 */
|
||||||
|
.pp-chips { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; }
|
||||||
|
.pp-chip {
|
||||||
display: inline-flex; align-items: center; gap: 6px; padding: 3px 6px 3px 4px;
|
display: inline-flex; align-items: center; gap: 6px; padding: 3px 6px 3px 4px;
|
||||||
border: 1px solid var(--line); border-radius: 999px; background: var(--surface);
|
border: 1px solid var(--line); border-radius: 999px; background: var(--surface);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
.att-chip img {
|
.pp-chip img {
|
||||||
width: 22px; height: 22px; border-radius: 50%; object-fit: cover; flex: 0 0 auto;
|
width: 22px; height: 22px; border-radius: 50%; object-fit: cover; flex: 0 0 auto;
|
||||||
background: #F1E7D6;
|
background: #F1E7D6;
|
||||||
}
|
}
|
||||||
.att-chip .att-x {
|
.pp-chip .pp-x {
|
||||||
border: 0; background: none; color: var(--muted); font-size: 15px;
|
border: 0; background: none; color: var(--muted); font-size: 15px;
|
||||||
line-height: 1; padding: 0 2px; cursor: pointer;
|
line-height: 1; padding: 0 2px; cursor: pointer;
|
||||||
}
|
}
|
||||||
.att-chip .att-x:hover { color: #c0392b; }
|
.pp-chip .pp-x:hover { color: #c0392b; }
|
||||||
.att-modal {
|
.pp-chip-empty { color: var(--muted); border-style: dashed; padding: 3px 10px; }
|
||||||
|
.pp-modal {
|
||||||
position: fixed; inset: 0; z-index: 60; display: flex; align-items: center;
|
position: fixed; inset: 0; z-index: 60; display: flex; align-items: center;
|
||||||
justify-content: center; background: rgba(0, 0, 0, .35); padding: 16px;
|
justify-content: center; background: rgba(0, 0, 0, .35); padding: 16px;
|
||||||
}
|
}
|
||||||
.att-modal[hidden] { display: none; }
|
.pp-modal[hidden] { display: none; }
|
||||||
.att-modal-box {
|
.pp-modal-box {
|
||||||
background: var(--surface); border-radius: 12px; box-shadow: 0 12px 40px rgba(0, 0, 0, .2);
|
background: var(--surface); border-radius: 12px; box-shadow: 0 12px 40px rgba(0, 0, 0, .2);
|
||||||
width: min(420px, 100%); max-height: 70vh; display: flex; flex-direction: column;
|
width: min(420px, 100%); max-height: 70vh; display: flex; flex-direction: column;
|
||||||
padding: 14px 16px; box-sizing: border-box;
|
padding: 14px 16px; box-sizing: border-box;
|
||||||
}
|
}
|
||||||
.att-modal-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; }
|
.pp-modal-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; }
|
||||||
.att-close {
|
.pp-close {
|
||||||
border: 0; background: none; font-size: 20px; line-height: 1;
|
border: 0; background: none; font-size: 20px; line-height: 1;
|
||||||
color: var(--muted); cursor: pointer; padding: 2px 6px;
|
color: var(--muted); cursor: pointer; padding: 2px 6px;
|
||||||
}
|
}
|
||||||
.att-modal-box .input { margin-bottom: 8px; }
|
.pp-modal-box .input { margin-bottom: 8px; }
|
||||||
.att-list { overflow-y: auto; border: 1px solid var(--line); border-radius: 8px; }
|
.pp-list { overflow-y: auto; border: 1px solid var(--line); border-radius: 8px; }
|
||||||
.att-item {
|
.pp-item {
|
||||||
display: flex; align-items: center; gap: 8px; padding: 8px 12px; cursor: pointer;
|
display: flex; align-items: center; gap: 8px; padding: 8px 12px; cursor: pointer;
|
||||||
border-bottom: 1px solid var(--line-2); font-size: 14px;
|
border-bottom: 1px solid var(--line-2); font-size: 14px;
|
||||||
}
|
}
|
||||||
.att-item:last-child { border-bottom: 0; }
|
/* ⚠️ 这条不能省:.pp-item 自己写了 display:flex,作者样式压过浏览器
|
||||||
.att-item:hover { background: var(--line-2); }
|
[hidden] { display:none },缺了它「过滤」就成了摆设 —— 名字和拼音都搜不动。
|
||||||
.att-item input { accent-color: var(--brand); flex: 0 0 auto; }
|
弹窗 .pp-modal 同理(上面已有),凡是**自己声明了 display** 的元素要藏都得补一条。 */
|
||||||
.att-item .att-av {
|
.pp-item[hidden] { display: none; }
|
||||||
|
.pp-item:last-child { border-bottom: 0; }
|
||||||
|
.pp-item:hover { background: var(--line-2); }
|
||||||
|
.pp-item input { accent-color: var(--brand); flex: 0 0 auto; }
|
||||||
|
.pp-item .pp-av {
|
||||||
width: 30px; height: 30px; border-radius: 50%; object-fit: cover; flex: 0 0 auto;
|
width: 30px; height: 30px; border-radius: 50%; object-fit: cover; flex: 0 0 auto;
|
||||||
background: #F1E7D6;
|
background: #F1E7D6;
|
||||||
}
|
}
|
||||||
.att-item .att-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.pp-item .pp-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.att-item .att-initials { margin-left: auto; color: var(--muted); font-size: 12px; }
|
.pp-item .pp-initials { margin-left: auto; color: var(--muted); font-size: 12px; }
|
||||||
.att-empty { padding: 10px 12px; color: var(--muted); font-size: 13px; }
|
.pp-item-none .pp-name { color: var(--muted); }
|
||||||
|
.pp-empty { padding: 10px 12px; color: var(--muted); font-size: 13px; }
|
||||||
|
.pp-empty[hidden] { display: none; }
|
||||||
/* ---- 豆瓣自动补充(v10):片名行 + 下拉候选 + 海报预览 ---- */
|
/* ---- 豆瓣自动补充(v10):片名行 + 下拉候选 + 海报预览 ---- */
|
||||||
.db-title-row { display: flex; gap: 8px; align-items: center; }
|
.db-title-row { display: flex; gap: 8px; align-items: center; }
|
||||||
.db-title-row .input { flex: 1 1 auto; }
|
.db-title-row .input { flex: 1 1 auto; }
|
||||||
@@ -1764,20 +1951,114 @@ textarea.input { min-height: 76px; resize: vertical; }
|
|||||||
⚠️ 断点必须是 **720px**(站点既有的"窄屏留白 16px"断点),不能用 860:
|
⚠️ 断点必须是 **720px**(站点既有的"窄屏留白 16px"断点),不能用 860:
|
||||||
746px 宽时若只改顶栏留白,就会出现「顶栏 x=16、正文 x=24」错位 8px
|
746px 宽时若只改顶栏留白,就会出现「顶栏 x=16、正文 x=24」错位 8px
|
||||||
(几何检查会直接报出来)。窄屏规则只在站点真正切到小留白的那一档生效。 */
|
(几何检查会直接报出来)。窄屏规则只在站点真正切到小留白的那一档生效。 */
|
||||||
|
/* ==================== 月 / 周 / 天 三选一的分段控件 ==================== */
|
||||||
|
/* 三个按钮连成一条(相邻边各留一半边框,拼起来才是一整条外框),
|
||||||
|
当前那个用模块主题色填底 —— 一眼看出自己在哪种看法上。 */
|
||||||
|
.seg { display: inline-flex; }
|
||||||
|
.seg-btn {
|
||||||
|
padding: 6px 14px; font-size: 13px; line-height: 1;
|
||||||
|
color: var(--muted); background: var(--surface);
|
||||||
|
border: 1px solid var(--line); text-decoration: none;
|
||||||
|
}
|
||||||
|
.seg-btn + .seg-btn { border-left: 0; }
|
||||||
|
.seg-btn:first-child { border-radius: var(--radius-s) 0 0 var(--radius-s); }
|
||||||
|
.seg-btn:last-child { border-radius: 0 var(--radius-s) var(--radius-s) 0; }
|
||||||
|
.seg-btn:hover { background: var(--surface-2, #FDFBF7); text-decoration: none; }
|
||||||
|
.seg-btn.is-on {
|
||||||
|
background: var(--brand); border-color: var(--brand); color: #fff; font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==================== 周 / 天:按小时铺的时间网格 ==================== */
|
||||||
|
/* 结构:左边一条"小时刻度"沟槽(tg-gutter),右边 N 个日期列(tg-col)。
|
||||||
|
列高固定 = 24 小时 × 每小时 44px,日程块按 top/height 百分比**绝对定位**,
|
||||||
|
所以"09:15 的 20 分钟"就是一小条,不会跟 9 点整的会挤成一样高。 */
|
||||||
|
.cal-tg-card { padding: 0; overflow: auto; }
|
||||||
|
.tg-head { display: flex; position: sticky; top: 0; z-index: 3;
|
||||||
|
background: var(--surface); border-bottom: 1px solid var(--line); }
|
||||||
|
.tg-gutter {
|
||||||
|
flex: 0 0 56px; width: 56px; border-right: 1px solid var(--line);
|
||||||
|
display: flex; flex-direction: column; align-items: flex-end;
|
||||||
|
}
|
||||||
|
.tg-dayname {
|
||||||
|
flex: 1 1 0; min-width: 0; padding: 8px 6px; text-align: center;
|
||||||
|
border-left: 1px solid var(--line-2);
|
||||||
|
}
|
||||||
|
.tg-dayname:first-of-type { border-left: 0; }
|
||||||
|
.tg-dayname.is-today { background: #FDF6F1; }
|
||||||
|
.tg-wd { display: block; font-size: 11px; color: var(--faint); }
|
||||||
|
.tg-dd { display: block; font-size: 16px; font-weight: 600; font-variant-numeric: tabular-nums; }
|
||||||
|
.tg-dayname.is-today .tg-dd { color: var(--brand); }
|
||||||
|
|
||||||
|
.tg-allday { display: flex; border-bottom: 1px solid var(--line); background: #FDFBF7; }
|
||||||
|
.tg-allday .tg-gutter { padding: 6px 6px; }
|
||||||
|
.tg-tag { font-size: 11px; color: var(--faint); }
|
||||||
|
.tg-adcell {
|
||||||
|
flex: 1 1 0; min-width: 0; padding: 4px 4px 6px; border-left: 1px solid var(--line-2);
|
||||||
|
display: flex; flex-direction: column; gap: 3px;
|
||||||
|
}
|
||||||
|
.tg-adcell:first-of-type { border-left: 0; }
|
||||||
|
|
||||||
|
.tg-body { display: flex; position: relative; }
|
||||||
|
.tg-hours { padding: 0; }
|
||||||
|
.tg-hr {
|
||||||
|
height: 44px; padding-right: 6px; padding-top: 2px;
|
||||||
|
font-size: 11px; color: var(--faint); font-variant-numeric: tabular-nums;
|
||||||
|
border-top: 1px solid var(--line-2); box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.tg-hr:first-child { border-top: 0; }
|
||||||
|
.tg-col {
|
||||||
|
flex: 1 1 0; min-width: 0; position: relative;
|
||||||
|
height: 1056px; /* 24 × 44px,与 .tg-hr 的高度必须一致 */
|
||||||
|
border-left: 1px solid var(--line-2); box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.tg-col:first-of-type { border-left: 0; }
|
||||||
|
.tg-col.is-today { background: #FDF6F1; }
|
||||||
|
.tg-line { display: block; height: 44px; border-top: 1px solid var(--line-2); box-sizing: border-box; }
|
||||||
|
.tg-line:first-child { border-top: 0; }
|
||||||
|
.tg-block {
|
||||||
|
position: absolute; left: 2px; right: 2px;
|
||||||
|
display: block; overflow: hidden; padding: 2px 5px;
|
||||||
|
border-radius: 4px; color: #fff; font-size: 11px; line-height: 1.35;
|
||||||
|
text-decoration: none; box-sizing: border-box;
|
||||||
|
border: 1px solid rgba(255,255,255,.35);
|
||||||
|
}
|
||||||
|
.tg-block:hover { text-decoration: none; filter: brightness(1.1); z-index: 2; }
|
||||||
|
.tg-block b { font-weight: 600; margin-right: 3px; }
|
||||||
|
|
||||||
|
/* 天视图只有一列,把列拉宽好读;周视图 7 列时列宽由 flex 平分 */
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
/* 窄屏留不住 56px 的刻度沟槽 + 7 列,改成整体可横向滚动(.cal-tg-card 已 overflow:auto),
|
||||||
|
给每列一个最小宽度,滚动着看而不是把字挤成一团。 */
|
||||||
|
.tg-col, .tg-dayname, .tg-adcell { min-width: 92px; }
|
||||||
|
.cal-tg-card { -webkit-overflow-scrolling: touch; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ⚠️ v24:导航从 7 个模块加到 9 个(纪念日 / 财务)之后,**746px 就放不下了**,
|
||||||
|
而"放不下"的表现不是换行、是顶栏被撑宽 20.5px:
|
||||||
|
`.nav` 内部产生横向滚动条(哪怕已用 scrollbar-width:none 隐藏),
|
||||||
|
`.topbar-inner` 的可用宽度就比 `.container` 少一档,
|
||||||
|
几何检查直接报「顶栏右 742.5 / 正文右 722.0」。
|
||||||
|
所以**不能**只在 720px 断点里给 .nav 加快收缩 ——
|
||||||
|
那样 721~1080 这一段区间没人管,正是出问题的地方。
|
||||||
|
正确做法:把「导航可收缩 + 横向滚动」提到**无媒体查询的基态**,
|
||||||
|
只在真的窄到需要缩留白时才改 padding(下面 720px 那段)。 */
|
||||||
|
.nav {
|
||||||
|
/* flex:1 1 auto + min-width:0 才真的会"被压缩";只写 min-width 时它仍是 auto 宽度,
|
||||||
|
内容一多就把顶栏撑出去(这就是 746px 那 20.5px 的来源)。 */
|
||||||
|
flex: 1 1 auto; min-width: 0;
|
||||||
|
overflow-x: auto; overscroll-behavior-x: contain; scrollbar-width: none;
|
||||||
|
}
|
||||||
|
.nav::-webkit-scrollbar { display: none; }
|
||||||
|
.nav a { flex: 0 0 auto; }
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
.topbar-inner { gap: 12px; padding: 0 16px; }
|
.topbar-inner { gap: 12px; padding: 0 16px; }
|
||||||
/* min-width:0 是关键:flex 子项默认 min-width:auto,会拒绝被压缩,
|
/* min-width:0 是关键:flex 子项默认 min-width:auto,会拒绝被压缩,
|
||||||
于是内部再 overflow-x 也撑不出滚动条,而是把父级一起顶宽。 */
|
于是内部再 overflow-x 也撑不出滚动条,而是把父级一起顶宽。 */
|
||||||
.nav {
|
.nav {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
min-width: 0;
|
|
||||||
margin-left: 0;
|
margin-left: 0;
|
||||||
overflow-x: auto;
|
|
||||||
-webkit-overflow-scrolling: touch;
|
|
||||||
scrollbar-width: none; /* Firefox */
|
|
||||||
}
|
}
|
||||||
.nav::-webkit-scrollbar { display: none; } /* Chrome/Safari:藏掉滚动条,仍可滑 */
|
.nav a { padding: 6px 10px; }
|
||||||
.nav a { flex: 0 0 auto; padding: 6px 10px; }
|
|
||||||
.topbar-right { margin-left: 0; gap: 6px; }
|
.topbar-right { margin-left: 0; gap: 6px; }
|
||||||
/* 窄屏把「家书」两个字收起来,只留图标 —— 名字占的宽度正好够多放两个模块 */
|
/* 窄屏把「家书」两个字收起来,只留图标 —— 名字占的宽度正好够多放两个模块 */
|
||||||
.brand span { display: none; }
|
.brand span { display: none; }
|
||||||
|
|||||||
@@ -0,0 +1,189 @@
|
|||||||
|
/**
|
||||||
|
* 全局人员选择器(v24 用户第 11 条)
|
||||||
|
*
|
||||||
|
* 一个页面里可能有多个 .pp(观影同行人 / 旅程同行人 / 资源归属 / 共享给谁 …),
|
||||||
|
* 所以这里按容器逐个初始化,状态全挂在容器自己身上,互不影响。
|
||||||
|
*
|
||||||
|
* 三件事:
|
||||||
|
* ① 汉字 → 拼音首字母(Intl 拼音排序 + 23 个锚字二分,零依赖)
|
||||||
|
* ② 搜索:名字 / 显示名 / 首字母 三者任一命中就留下
|
||||||
|
* ③ chips 与弹窗里的勾选状态双向同步
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------
|
||||||
|
* 汉字 → 拼音首字母
|
||||||
|
*
|
||||||
|
* 做法:a..z 各挑一个「锚字」(没有 i / u / v 开头的常用字,所以是 23 个),
|
||||||
|
* 用拼音 collation 二分定位某个汉字落在哪一段 —— 落在「垃」之后、「妈」之前
|
||||||
|
* 就是 l。整套零依赖,不需要塞一张几万字的拼音表进来。
|
||||||
|
*
|
||||||
|
* ⚠️ 浏览器不支持拼音排序时返回 null:此时只按名字搜,不硬出错误结果。
|
||||||
|
* ---------------------------------------------------------------- */
|
||||||
|
var initialsOf = (function () {
|
||||||
|
var anchors = '阿八擦搭娥发嘎哈击喀垃妈拿哦啪期然撒塌挖昔压匝';
|
||||||
|
var letters = 'abcdefghjklmnopqrstwxyz';
|
||||||
|
var coll;
|
||||||
|
try { coll = new Intl.Collator('zh-Hans-CN-u-co-pinyin'); } catch (e) { return null; }
|
||||||
|
// 排序没真正生效时 compare 会退化成按码点/笔画,这里直接认不出来 → 放弃拼音
|
||||||
|
if (!coll || coll.compare('阿', '匝') >= 0) { return null; }
|
||||||
|
return function (s) {
|
||||||
|
var out = '';
|
||||||
|
for (var i = 0; i < s.length; i++) {
|
||||||
|
var ch = s.charAt(i);
|
||||||
|
if (/[a-zA-Z]/.test(ch)) { out += ch.toUpperCase(); continue; }
|
||||||
|
if (ch.charCodeAt(0) < 0x2E80) { continue; } // 非 CJK:不产字母
|
||||||
|
var lo = 0, hi = anchors.length - 1, best = -1;
|
||||||
|
while (lo <= hi) {
|
||||||
|
var mid = (lo + hi) >> 1;
|
||||||
|
if (coll.compare(ch, anchors.charAt(mid)) >= 0) { best = mid; lo = mid + 1; }
|
||||||
|
else { hi = mid - 1; }
|
||||||
|
}
|
||||||
|
if (best >= 0) { out += letters.charAt(best); }
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* q 是否是 s 的**子序列**。
|
||||||
|
* 为什么不用 indexOf:「杨永全」的首字母是 yyq,用户很自然地会打 yq ——
|
||||||
|
* 连续子串匹配不到,子序列能。选人这种小列表宁可放宽一点,也别让人以为坏了。
|
||||||
|
*/
|
||||||
|
function isSubSeq(q, s) {
|
||||||
|
if (!q) { return true; }
|
||||||
|
var i = 0;
|
||||||
|
for (var j = 0; j < s.length && i < q.length; j++) {
|
||||||
|
if (s.charAt(j) === q.charAt(i)) { i++; }
|
||||||
|
}
|
||||||
|
return i === q.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function init(root) {
|
||||||
|
var id = root.getAttribute('data-pp') || '';
|
||||||
|
var isMulti = root.getAttribute('data-pp-mode') !== 'single';
|
||||||
|
var modal = root.querySelector('.pp-modal');
|
||||||
|
var openBtn = root.querySelector('.pp-open');
|
||||||
|
var closeBtn = root.querySelector('.pp-close');
|
||||||
|
var search = root.querySelector('.pp-search');
|
||||||
|
var list = root.querySelector('.pp-list');
|
||||||
|
var chips = root.querySelector('.pp-chips');
|
||||||
|
var emptyTip = root.querySelector('.pp-empty');
|
||||||
|
if (!modal || !list || !chips) { return; }
|
||||||
|
|
||||||
|
// 预计算每个人的首字母串,挂在 data-initials 上(列表再长也只是算一次)
|
||||||
|
Array.prototype.forEach.call(list.querySelectorAll('.pp-item'), function (el) {
|
||||||
|
var s = (el.getAttribute('data-name') || '') + (el.getAttribute('data-shown') || '');
|
||||||
|
var ini = initialsOf ? initialsOf(s) : '';
|
||||||
|
el.setAttribute('data-initials', ini.toLowerCase());
|
||||||
|
var badge = el.querySelector('.pp-initials');
|
||||||
|
if (badge && ini) { badge.textContent = ini; badge.hidden = false; }
|
||||||
|
});
|
||||||
|
|
||||||
|
function syncChips() {
|
||||||
|
chips.innerHTML = '';
|
||||||
|
var any = false;
|
||||||
|
Array.prototype.forEach.call(list.querySelectorAll('.pp-item input:checked'), function (box) {
|
||||||
|
if (box.value === '0') { return; } // 「不选」不进 chips
|
||||||
|
var item = box.closest('.pp-item');
|
||||||
|
if (!item) { return; }
|
||||||
|
any = true;
|
||||||
|
// ⚠️ 必须点名取 .pp-name:.pp-item 里第一个 span 是隐藏的首字母占位,
|
||||||
|
// 早年靠「取第一个 span 当名字」碰巧成立,加了首字母徽标后 chip 就没名字了。
|
||||||
|
var nameEl = item.querySelector('.pp-name');
|
||||||
|
var av = item.querySelector('.pp-av');
|
||||||
|
var chip = document.createElement('span');
|
||||||
|
chip.className = 'pp-chip';
|
||||||
|
chip.setAttribute('data-id', box.value);
|
||||||
|
if (av) {
|
||||||
|
var im = document.createElement('img');
|
||||||
|
im.src = av.getAttribute('src'); // 服务端已 e() 过,这里原样搬
|
||||||
|
im.alt = '';
|
||||||
|
chip.appendChild(im);
|
||||||
|
}
|
||||||
|
var nm = document.createElement('span');
|
||||||
|
nm.className = 'pp-name';
|
||||||
|
nm.textContent = nameEl ? nameEl.textContent : box.value;
|
||||||
|
chip.appendChild(nm);
|
||||||
|
if (isMulti) {
|
||||||
|
var x = document.createElement('button');
|
||||||
|
x.type = 'button'; x.className = 'pp-x'; x.textContent = '×';
|
||||||
|
x.title = '移除'; x.setAttribute('data-id', box.value);
|
||||||
|
chip.appendChild(x);
|
||||||
|
}
|
||||||
|
chips.appendChild(chip);
|
||||||
|
});
|
||||||
|
// ⚠️ 多选且一个都没选时**不要**塞占位 chip:.pp-chip 自己写了 display:inline-flex,
|
||||||
|
// 再给它 hidden 也藏不掉(作者样式压过 UA 的 [hidden]),会留下一个空胶囊。
|
||||||
|
if (!any && !isMulti) {
|
||||||
|
var ph = document.createElement('span');
|
||||||
|
ph.className = 'pp-chip pp-chip-empty';
|
||||||
|
ph.textContent = '未选择';
|
||||||
|
chips.appendChild(ph);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFilter() {
|
||||||
|
var q = (search.value || '').trim().toLowerCase();
|
||||||
|
var shown = 0;
|
||||||
|
Array.prototype.forEach.call(list.querySelectorAll('.pp-item'), function (el) {
|
||||||
|
var hit = !q ||
|
||||||
|
(el.getAttribute('data-name') || '').toLowerCase().indexOf(q) >= 0 ||
|
||||||
|
(el.getAttribute('data-shown') || '').toLowerCase().indexOf(q) >= 0 ||
|
||||||
|
isSubSeq(q, el.getAttribute('data-initials') || '');
|
||||||
|
el.hidden = !hit;
|
||||||
|
if (hit) { shown++; }
|
||||||
|
});
|
||||||
|
if (emptyTip) { emptyTip.hidden = shown > 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function openModal() {
|
||||||
|
modal.hidden = false;
|
||||||
|
search.value = '';
|
||||||
|
applyFilter();
|
||||||
|
syncChips();
|
||||||
|
search.focus();
|
||||||
|
}
|
||||||
|
function closeModal() { modal.hidden = true; }
|
||||||
|
|
||||||
|
if (openBtn) { openBtn.addEventListener('click', openModal); }
|
||||||
|
if (closeBtn) { closeBtn.addEventListener('click', closeModal); }
|
||||||
|
modal.addEventListener('click', function (e) { if (e.target === modal) { closeModal(); } });
|
||||||
|
search.addEventListener('input', applyFilter);
|
||||||
|
|
||||||
|
chips.addEventListener('click', function (e) {
|
||||||
|
var x = e.target.closest ? e.target.closest('.pp-x') : null;
|
||||||
|
if (!x) { return; }
|
||||||
|
var gid = x.getAttribute('data-id');
|
||||||
|
Array.prototype.forEach.call(list.querySelectorAll('.pp-item input'), function (box) {
|
||||||
|
if (box.value === gid) { box.checked = false; }
|
||||||
|
});
|
||||||
|
syncChips();
|
||||||
|
});
|
||||||
|
|
||||||
|
/* 多选:勾一个 = 出 chip + 关弹窗(再添加就再点一次「+ 添加人」);
|
||||||
|
单选:点一个 = 换掉当前这个人 + 关弹窗 */
|
||||||
|
list.addEventListener('change', function (e) {
|
||||||
|
if (!e.target || e.target.tagName !== 'INPUT') { return; }
|
||||||
|
syncChips();
|
||||||
|
if (e.target.checked) { closeModal(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('keydown', function (e) {
|
||||||
|
if (e.key === 'Escape' && !modal.hidden) { closeModal(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
syncChips();
|
||||||
|
}
|
||||||
|
|
||||||
|
function boot() {
|
||||||
|
Array.prototype.forEach.call(document.querySelectorAll('.pp'), init);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', boot);
|
||||||
|
} else {
|
||||||
|
boot();
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
/**
|
||||||
|
* 全局附件预览(v24 用户第 3 条)
|
||||||
|
*
|
||||||
|
* 点缩略图 → **在本页**弹一层把内容放出来,不再新开标签页。
|
||||||
|
* · 图片 → <img>
|
||||||
|
* · 视频 → <video controls autoplay>(media.php 支持 Range,进度条能拖)
|
||||||
|
* · PDF → <iframe>(用浏览器自带的阅读器)
|
||||||
|
* · 其它(Word / Excel / …)→ 根本不进这里:那段链接指向的是下载地址,点了就下。
|
||||||
|
*
|
||||||
|
* 右上角恒有一个「下载」按钮(用户原话:所有的预览页面,右上角都有一个下载按钮)。
|
||||||
|
*
|
||||||
|
* 触发方式是数据属性,不用给每个地方写一遍初始化:
|
||||||
|
* <a href="…" data-prev="image|video|pdf" data-src="…" data-dl="…" data-name="…">
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var box = document.getElementById('pv');
|
||||||
|
if (!box) { return; }
|
||||||
|
var stage = box.querySelector('.pv-stage');
|
||||||
|
var titleEl = box.querySelector('.pv-name');
|
||||||
|
var metaEl = box.querySelector('.pv-meta');
|
||||||
|
var dlEl = box.querySelector('.pv-dl');
|
||||||
|
var closeEl = box.querySelector('.pv-close');
|
||||||
|
var opener = null;
|
||||||
|
|
||||||
|
function clear() {
|
||||||
|
// ⚠️ 换内容要**整个重建**节点,不能只改 src:
|
||||||
|
// <video> 改 src 不会自动卸载上一支(声音会叠着放),<iframe> 同理。
|
||||||
|
stage.innerHTML = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function open(trigger) {
|
||||||
|
var kind = trigger.getAttribute('data-prev') || 'image';
|
||||||
|
var src = trigger.getAttribute('data-src') || trigger.getAttribute('href') || '';
|
||||||
|
var dl = trigger.getAttribute('data-dl') || src;
|
||||||
|
if (!src) { return; }
|
||||||
|
opener = trigger;
|
||||||
|
clear();
|
||||||
|
|
||||||
|
var el;
|
||||||
|
if (kind === 'video') {
|
||||||
|
el = document.createElement('video');
|
||||||
|
el.className = 'pv-video';
|
||||||
|
el.src = src;
|
||||||
|
el.controls = true;
|
||||||
|
el.autoplay = true;
|
||||||
|
el.playsInline = true;
|
||||||
|
} else if (kind === 'pdf') {
|
||||||
|
el = document.createElement('iframe');
|
||||||
|
el.className = 'pv-pdf';
|
||||||
|
el.src = src;
|
||||||
|
// PDF 是"另一份文档",别让它拿到本页的 window
|
||||||
|
el.setAttribute('sandbox', 'allow-same-origin allow-scripts');
|
||||||
|
} else {
|
||||||
|
el = document.createElement('img');
|
||||||
|
el.className = 'pv-img';
|
||||||
|
el.src = src;
|
||||||
|
el.alt = '';
|
||||||
|
}
|
||||||
|
stage.appendChild(el);
|
||||||
|
|
||||||
|
titleEl.textContent = trigger.getAttribute('data-name') || '';
|
||||||
|
var sz = trigger.getAttribute('data-size') || '';
|
||||||
|
metaEl.textContent = sz;
|
||||||
|
dlEl.setAttribute('href', dl);
|
||||||
|
// 下载要带上真实文件名:download 属性 + 服务端 Content-Disposition 双保险
|
||||||
|
var nm = trigger.getAttribute('data-name') || '';
|
||||||
|
if (nm) { dlEl.setAttribute('download', nm); } else { dlEl.removeAttribute('download'); }
|
||||||
|
|
||||||
|
box.hidden = false;
|
||||||
|
box.setAttribute('aria-hidden', 'false');
|
||||||
|
closeEl.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
box.hidden = true;
|
||||||
|
box.setAttribute('aria-hidden', 'true');
|
||||||
|
clear(); // 停掉视频 / 卸掉 PDF
|
||||||
|
if (opener && opener.focus) { opener.focus(); }
|
||||||
|
opener = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('click', function (e) {
|
||||||
|
var t = e.target && e.target.closest ? e.target.closest('[data-prev]') : null;
|
||||||
|
if (t) {
|
||||||
|
// ⚠️ 只拦"可预览"的:Word/Excel 那类链接没有 data-prev,
|
||||||
|
// 浏览器照常走 href(就是下载地址),不会被这里挡住。
|
||||||
|
e.preventDefault();
|
||||||
|
open(t);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 点遮罩(stage 本身或弹层空白处)关闭;点内容不关
|
||||||
|
if (!box.hidden && (e.target === box || e.target === stage)) { close(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
closeEl.addEventListener('click', close);
|
||||||
|
document.addEventListener('keydown', function (e) {
|
||||||
|
if (!box.hidden && e.key === 'Escape') { close(); }
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* 家书 FamilyBook · CalDAV 入口(v24 用户第 7 条后半)
|
||||||
|
*
|
||||||
|
* 「把我的日历本,通过 CalDAV 协议分享给别人」—— 别人在他们的
|
||||||
|
* iOS 日历 / 安卓 DAVx⁵ / Thunderbird / 群晖日历里填上这个地址就能订阅到。
|
||||||
|
*
|
||||||
|
* 地址:https://<站点>/dav/
|
||||||
|
* 账号:手机号或姓名(同登录)
|
||||||
|
* 口令:**CalDAV 专用口令**(在「日程 → 日历本」页生成,不是登录口令)
|
||||||
|
*
|
||||||
|
* ⚠️ 为什么单独一个文件而不是塞进 index.php:
|
||||||
|
* CalDAV 用的是 HTTP 方法名(PROPFIND / REPORT / OPTIONS / PUT),
|
||||||
|
* index.php 那套 `?r=xxx` 路由是 GET/POST 世界里的东西,
|
||||||
|
* 混在一起会让两边都变难懂。这里只做"按方法分派",业务在 App\CalDav 里。
|
||||||
|
*
|
||||||
|
* 本阶段是**只读**实现(RFC 4791 的子集):写方法一律 403。
|
||||||
|
* 与其"半支持"(客户端以为写成功了、服务端却没落库/没合冲突),不如明确拒绝。
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require __DIR__ . '/app/bootstrap.php';
|
||||||
|
|
||||||
|
use App\Auth;
|
||||||
|
use App\CalDav;
|
||||||
|
use App\Calendar;
|
||||||
|
|
||||||
|
/** 统一的失败出口(CalDAV 客户端只认状态码,正文给点人话便于排查) */
|
||||||
|
function dav_fail(int $code, string $msg): void
|
||||||
|
{
|
||||||
|
http_response_code($code);
|
||||||
|
header('Content-Type: text/plain; charset=utf-8');
|
||||||
|
echo $msg;
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 1. 鉴权:HTTP Basic(DAVx⁵ / iOS 都原生支持)----
|
||||||
|
$user = $_SERVER['PHP_AUTH_USER'] ?? null;
|
||||||
|
$pass = $_SERVER['PHP_AUTH_PW'] ?? null;
|
||||||
|
// 有些 SAPI 不填 PHP_AUTH_USER,得自己从 Authorization 头里拆
|
||||||
|
if ($user === null && !empty($_SERVER['HTTP_AUTHORIZATION'])) {
|
||||||
|
if (preg_match('/^Basic\s+(.+)$/i', (string)$_SERVER['HTTP_AUTHORIZATION'], $m)) {
|
||||||
|
$decoded = base64_decode($m[1], true);
|
||||||
|
if ($decoded !== false && strpos($decoded, ':') !== false) {
|
||||||
|
[$user, $pass] = explode(':', $decoded, 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$who = Auth::caldavAuth($user, $pass);
|
||||||
|
if (!$who) {
|
||||||
|
header('WWW-Authenticate: Basic realm="FamilyBook CalDAV"');
|
||||||
|
dav_fail(401, '需要 CalDAV 口令(在「日程 → 日历本」页生成,不是登录密码)。');
|
||||||
|
}
|
||||||
|
$familyId = (int)$who['family_id'];
|
||||||
|
$me = (int)$who['person_id'];
|
||||||
|
|
||||||
|
$method = strtoupper((string)($_SERVER['REQUEST_METHOD'] ?? 'GET'));
|
||||||
|
$path = (string)parse_url((string)($_SERVER['REQUEST_URI'] ?? '/dav/'), PHP_URL_PATH);
|
||||||
|
$hit = CalDav::parsePath($path);
|
||||||
|
$bookId = (int)$hit['book'];
|
||||||
|
$evId = (int)$hit['event'];
|
||||||
|
|
||||||
|
// ---- 2. 只读:写方法直接拒(明确拒比"写一半"好)----
|
||||||
|
if (in_array($method, ['PUT', 'DELETE', 'PROPPATCH', 'MKCALENDAR', 'POST', 'PATCH'], true)) {
|
||||||
|
dav_fail(403, '家书的 CalDAV 目前是只读订阅:请在本站里修改日程。');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 3. OPTIONS:客户端第一句就问"你支持什么" ----
|
||||||
|
if ($method === 'OPTIONS') {
|
||||||
|
http_response_code(200);
|
||||||
|
header('DAV: 1, 2, calendar-access');
|
||||||
|
header('Allow: OPTIONS, GET, HEAD, PROPFIND, REPORT');
|
||||||
|
header('MS-Author-Via: DAV');
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 4. 找不到那本 → 404(不要回 200 空列表:客户端会以为"这本是空的")----
|
||||||
|
$cal = null;
|
||||||
|
if ($bookId > 0) {
|
||||||
|
$cal = Calendar::mineOne($familyId, $me, $bookId);
|
||||||
|
if (!$cal) {
|
||||||
|
dav_fail(404, '没有这本日历。');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 日历本的显示名:带上是"谁的本"(共享来的本子同名时能分清) */
|
||||||
|
$dispName = static function (array $c) use ($me): string {
|
||||||
|
$nm = (string)$c['name'];
|
||||||
|
if ((int)($c['owner_id'] ?? 0) !== $me && !empty($c['owner_name'])) {
|
||||||
|
$nm .= '(' . (string)$c['owner_name'] . ')';
|
||||||
|
}
|
||||||
|
return $nm;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- 5. PROPFIND:清单 / 属性 / 资源列表 ----
|
||||||
|
if ($method === 'PROPFIND') {
|
||||||
|
$depth = (string)($_SERVER['HTTP_DEPTH'] ?? '0');
|
||||||
|
|
||||||
|
if ($bookId === 0) {
|
||||||
|
// 根:列出这个人可见的全部日历本
|
||||||
|
$parts = [];
|
||||||
|
foreach (CalDav::books($familyId, $me) as $c) {
|
||||||
|
$href = CalDav::hrefOf($c);
|
||||||
|
$parts[] = '<d:response><d:href>' . CalDav::x($href) . '</d:href>'
|
||||||
|
. '<d:propstat><d:prop>'
|
||||||
|
. '<d:resourcetype><c:calendar/></d:resourcetype>'
|
||||||
|
. '<d:displayname>' . CalDav::x($dispName($c)) . '</d:displayname>'
|
||||||
|
. '<c:calendar-description>' . CalDav::x('家书 · ' . (string)$c['name']) . '</c:calendar-description>'
|
||||||
|
. '<x:calendar-color xmlns:x="http://apple.com/ns/ical/">' . CalDav::x((string)$c['color']) . '</x:calendar-color>'
|
||||||
|
. '<c:supported-calendar-component-set><c:comp name="VEVENT"/></c:supported-calendar-component-set>'
|
||||||
|
. '<d:current-user-privilege-set><d:privilege><d:read/></d:privilege></d:current-user-privilege-set>'
|
||||||
|
. '</d:prop></d:propstat></d:response>';
|
||||||
|
}
|
||||||
|
CalDav::multis();
|
||||||
|
echo '<?xml version="1.0" encoding="utf-8"?>' . "\n"
|
||||||
|
. '<d:multistatus xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">'
|
||||||
|
. implode('', $parts) . '</d:multistatus>';
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($depth === '1') {
|
||||||
|
// 某一本里的资源清单:客户端拿这个 ETag 判断"要不要重新拉"
|
||||||
|
$rows = CalDav::rows($cal, $familyId, $me);
|
||||||
|
$parts = [];
|
||||||
|
foreach ($rows as $r) {
|
||||||
|
$rid = (int)($r['id'] ?? 0);
|
||||||
|
$uid = (string)($r['uid'] ?? '');
|
||||||
|
// 派生本(观影 / 旅程)没有 calendar_events.id,用 uid 段做资源名
|
||||||
|
$seg = $rid > 0 ? (string)$rid : preg_replace('/[^A-Za-z0-9._-]/', '_', $uid);
|
||||||
|
if ($seg === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$etag = '"' . md5($uid . '|' . (string)($r['starts_at'] ?? '') . '|' . (string)($r['updated_at'] ?? '')) . '"';
|
||||||
|
$parts[] = '<d:response><d:href>' . CalDav::x(CalDav::bookHref($bookId) . $seg . '.ics') . '</d:href>'
|
||||||
|
. '<d:propstat><d:prop>'
|
||||||
|
. '<d:getetag>' . CalDav::x($etag) . '</d:getetag>'
|
||||||
|
. '<d:resourcetype/>'
|
||||||
|
. '<d:getcontenttype>text/calendar; charset=utf-8</d:getcontenttype>'
|
||||||
|
. '</d:prop></d:propstat></d:response>';
|
||||||
|
}
|
||||||
|
CalDav::multis();
|
||||||
|
echo '<?xml version="1.0" encoding="utf-8"?>' . "\n"
|
||||||
|
. '<d:multistatus xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">'
|
||||||
|
. implode('', $parts) . '</d:multistatus>';
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Depth 0:这一天历本自身的属性
|
||||||
|
CalDav::multis();
|
||||||
|
echo '<?xml version="1.0" encoding="utf-8"?>' . "\n"
|
||||||
|
. '<d:multistatus xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">'
|
||||||
|
. '<d:response><d:href>' . CalDav::x(CalDav::bookHref($bookId)) . '</d:href>'
|
||||||
|
. '<d:propstat><d:prop>'
|
||||||
|
. '<d:resourcetype><d:collection/><c:calendar/></d:resourcetype>'
|
||||||
|
. '<d:displayname>' . CalDav::x($dispName($cal)) . '</d:displayname>'
|
||||||
|
. '<c:supported-calendar-component-set><c:comp name="VEVENT"/></c:supported-calendar-component-set>'
|
||||||
|
. '<d:current-user-privilege-set><d:privilege><d:read/></d:privilege></d:current-user-privilege-set>'
|
||||||
|
. '</d:prop></d:propstat></d:response></d:multistatus>';
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 6. GET / HEAD:整本导出成 ics(不少客户端就走这一条)----
|
||||||
|
if ($method === 'GET' || $method === 'HEAD') {
|
||||||
|
if (!$cal) {
|
||||||
|
dav_fail(404, '没有这本日历。');
|
||||||
|
}
|
||||||
|
$ics = CalDav::ics($cal, $familyId, $me);
|
||||||
|
$etag = '"' . md5($ics) . '"';
|
||||||
|
if (trim((string)($_SERVER['HTTP_IF_NONE_MATCH'] ?? '')) === $etag) {
|
||||||
|
http_response_code(304);
|
||||||
|
header('ETag: ' . $etag);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
http_response_code(200);
|
||||||
|
header('Content-Type: text/calendar; charset=utf-8');
|
||||||
|
header('ETag: ' . $etag);
|
||||||
|
header('Cache-Control: private, max-age=0, must-revalidate');
|
||||||
|
$fn = preg_replace('/[^A-Za-z0-9._-]/', '_', (string)$cal['name']);
|
||||||
|
header('Content-Disposition: inline; filename="' . $fn . '.ics"');
|
||||||
|
if ($method === 'GET') {
|
||||||
|
echo $ics;
|
||||||
|
}
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 7. REPORT:calendar-query(按时间范围) / calendar-multiget(按 href 取)----
|
||||||
|
if ($method === 'REPORT') {
|
||||||
|
if (!$cal) {
|
||||||
|
dav_fail(404, '没有这本日历。');
|
||||||
|
}
|
||||||
|
$bodyRaw = (string)file_get_contents('php://input');
|
||||||
|
|
||||||
|
// calendar-multiget:客户端点明要哪几条(我们按整本回,客户端自己挑)
|
||||||
|
$wantsMulti = stripos($bodyRaw, 'calendar-multiget') !== false;
|
||||||
|
|
||||||
|
// calendar-query 的时间范围(有就按范围裁;没有就整本)
|
||||||
|
$from = '';
|
||||||
|
$to = '';
|
||||||
|
if (preg_match('#<c:time-range[^>]*start="([^"]+)"#i', $bodyRaw, $m)) {
|
||||||
|
$from = substr($m[1], 0, 8); // 20260101T000000Z → 20260101
|
||||||
|
$from = substr($m[1], 0, 4) . '-' . substr($m[1], 4, 2) . '-' . substr($m[1], 6, 2);
|
||||||
|
}
|
||||||
|
if (preg_match('#<c:time-range[^>]*end="([^"]+)"#i', $bodyRaw, $m)) {
|
||||||
|
$to = substr($m[1], 0, 4) . '-' . substr($m[1], 4, 2) . '-' . substr($m[1], 6, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 单条资源请求(/dav/3.ics/12.ics)
|
||||||
|
if ($evId > 0) {
|
||||||
|
$one = CalDav::oneEvent($bookId, $evId, [$familyId, $me]);
|
||||||
|
if (!$one) {
|
||||||
|
dav_fail(404, '没有这条日程。');
|
||||||
|
}
|
||||||
|
$evIcs = \App\Ics::export([$one], (string)$cal['name']);
|
||||||
|
CalDav::multis();
|
||||||
|
echo '<?xml version="1.0" encoding="utf-8"?>' . "\n"
|
||||||
|
. '<d:multistatus xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">'
|
||||||
|
. '<d:response><d:href>' . CalDav::x(CalDav::bookHref($bookId) . $evId . '.ics') . '</d:href>'
|
||||||
|
. '<d:propstat><d:prop>'
|
||||||
|
. '<d:getetag>' . CalDav::x('"' . md5($evIcs) . '"') . '</d:getetag>'
|
||||||
|
. '<c:calendar-data>' . CalDav::x($evIcs) . '</c:calendar-data>'
|
||||||
|
. '</d:prop></d:propstat></d:response></d:multistatus>';
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows = CalDav::rows($cal, $familyId, $me);
|
||||||
|
$parts = [];
|
||||||
|
foreach ($rows as $r) {
|
||||||
|
$day = substr((string)($r['starts_at'] ?? ''), 0, 10);
|
||||||
|
if ($from !== '' && $day !== '' && $day < $from) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($to !== '' && $day !== '' && $day > $to) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$rid = (int)($r['id'] ?? 0);
|
||||||
|
$uid = (string)($r['uid'] ?? '');
|
||||||
|
$seg = $rid > 0 ? (string)$rid : preg_replace('/[^A-Za-z0-9._-]/', '_', $uid);
|
||||||
|
if ($seg === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$evIcs = \App\Ics::export([$r], (string)$cal['name']);
|
||||||
|
$parts[] = '<d:response><d:href>' . CalDav::x(CalDav::bookHref($bookId) . $seg . '.ics') . '</d:href>'
|
||||||
|
. '<d:propstat><d:prop>'
|
||||||
|
. '<d:getetag>' . CalDav::x('"' . md5($evIcs) . '"') . '</d:getetag>'
|
||||||
|
. '<c:calendar-data>' . CalDav::x($evIcs) . '</c:calendar-data>'
|
||||||
|
. '</d:prop></d:propstat></d:response>';
|
||||||
|
// calendar-multiget 时客户端自己按 href 挑;我们整本给,列表不长(家庭场景)
|
||||||
|
unset($wantsMulti);
|
||||||
|
}
|
||||||
|
CalDav::multis();
|
||||||
|
echo '<?xml version="1.0" encoding="utf-8"?>' . "\n"
|
||||||
|
. '<d:multistatus xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">'
|
||||||
|
. implode('', $parts) . '</d:multistatus>';
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 8. 其它方法一律 405(并告知支持哪些)----
|
||||||
|
header('Allow: OPTIONS, GET, HEAD, PROPFIND, REPORT');
|
||||||
|
dav_fail(405, '不支持这个请求方法。');
|
||||||
@@ -19,7 +19,10 @@ use App\Crypto;
|
|||||||
use App\Database;
|
use App\Database;
|
||||||
use App\Douban;
|
use App\Douban;
|
||||||
use App\Family;
|
use App\Family;
|
||||||
|
use App\Anniversary;
|
||||||
|
use App\Ics;
|
||||||
use App\Kinship;
|
use App\Kinship;
|
||||||
|
use App\Ledger;
|
||||||
use App\Movie;
|
use App\Movie;
|
||||||
use App\MovieReview;
|
use App\MovieReview;
|
||||||
use App\Module;
|
use App\Module;
|
||||||
@@ -293,6 +296,81 @@ function build_month_grid(string $ym, array $events): array
|
|||||||
return $weeks;
|
return $weeks;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 周视图 / 天视图的日期列(用户 2026-10-02 第 4 条)。
|
||||||
|
*
|
||||||
|
* 周视图固定 7 天;天视图就 1 天 —— 两者共用同一个"按小时铺的时间网格",
|
||||||
|
* 只是列数不同,所以放在一个函数里。
|
||||||
|
*
|
||||||
|
* @return array<int, array{date:string,w:int,day:int,is_today:bool}>
|
||||||
|
*/
|
||||||
|
function day_columns(string $anchor, int $span): array
|
||||||
|
{
|
||||||
|
$ts = strtotime($anchor);
|
||||||
|
if ($ts === false) {
|
||||||
|
$ts = time();
|
||||||
|
}
|
||||||
|
$w = (int)date('w', $ts); // 0=周日 … 6=周六
|
||||||
|
$mon = strtotime('-' . (($w + 6) % 7) . ' days', $ts); // 周一开头
|
||||||
|
$out = [];
|
||||||
|
for ($i = 0; $i < max(1, $span); $i++) {
|
||||||
|
$d = date('Y-m-d', strtotime('+' . $i . ' days', $mon));
|
||||||
|
$out[] = [
|
||||||
|
'date' => $d,
|
||||||
|
'w' => (int)date('w', strtotime($d)),
|
||||||
|
'day' => (int)date('j', strtotime($d)),
|
||||||
|
'is_today' => $d === date('Y-m-d'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把日程摆进「小时 × 日期列」的时间网格(周视图 / 天视图共用)。
|
||||||
|
*
|
||||||
|
* 与月视图的不同:这里**按时段定位**(top/height 用百分比),
|
||||||
|
* 所以"09:15 的 20 分钟会"在格子里就是一小条,而不是跟"9 点的会"挤成一样高。
|
||||||
|
*
|
||||||
|
* ⚠️ 全天日程不进时间格 —— 它们没有时段,硬塞进 0 点会变成一条压在最上面的怪东西。
|
||||||
|
* 单独挂在顶上的一行(跟群晖日历一样)。
|
||||||
|
*
|
||||||
|
* @return array{all_day:array,timed:array} all_day 按列分组;timed 按列分组,含 top/height
|
||||||
|
*/
|
||||||
|
function build_time_grid(array $events, array $cols): array
|
||||||
|
{
|
||||||
|
$dayIndex = [];
|
||||||
|
foreach ($cols as $i => $c) {
|
||||||
|
$dayIndex[(string)$c['date']] = $i;
|
||||||
|
}
|
||||||
|
$allDay = array_fill(0, count($cols), []);
|
||||||
|
$timed = array_fill(0, count($cols), []);
|
||||||
|
|
||||||
|
foreach ($events as $e) {
|
||||||
|
$ci = $dayIndex[(string)$e['date']] ?? null;
|
||||||
|
if ($ci === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!empty($e['all_day'])) {
|
||||||
|
$allDay[$ci][] = $e;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$t = (string)($e['time'] ?? '');
|
||||||
|
// 有具体时刻才进时间格;没有时刻又不是全天的(如任务全天),按全天处理
|
||||||
|
if ($t === '' || !preg_match('/^(\d{2}):(\d{2})/', $t, $m)) {
|
||||||
|
$allDay[$ci][] = $e;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$startMin = ((int)$m[1]) * 60 + (int)$m[2];
|
||||||
|
// 没有结束时刻的按 30 分钟画一条最短的可见块(不然高度是 0,等于看不见)
|
||||||
|
$endMin = $startMin + 30;
|
||||||
|
$timed[$ci][] = $e + [
|
||||||
|
'top' => round($startMin / 1440 * 100, 4),
|
||||||
|
'height' => round(max(30, $endMin - $startMin) / 1440 * 100, 4),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return ['all_day' => $allDay, 'timed' => $timed];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 读任务的「截止日期(+ 可选时刻)」。
|
* 读任务的「截止日期(+ 可选时刻)」。
|
||||||
*
|
*
|
||||||
@@ -301,6 +379,13 @@ function build_month_grid(string $ym, array $events): array
|
|||||||
*/
|
*/
|
||||||
function read_due_input(): ?string
|
function read_due_input(): ?string
|
||||||
{
|
{
|
||||||
|
// v24 第 5 条:截止时刻与全局其它地方一样走 `_datetime.php`(日期 + 时/分/秒一起选,
|
||||||
|
// 精确到秒)。下面那段 due_date / due_time 只是老表单的兜底,别删 ——
|
||||||
|
// 旧的链接、书签、脚本还在按老名字提交,删了就静默变成"没有截止日期"。
|
||||||
|
$full = read_dt_input('due_at');
|
||||||
|
if ($full !== null) {
|
||||||
|
return $full;
|
||||||
|
}
|
||||||
$d = trim((string)($_POST['due_date'] ?? ''));
|
$d = trim((string)($_POST['due_date'] ?? ''));
|
||||||
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $d)) {
|
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $d)) {
|
||||||
return null;
|
return null;
|
||||||
@@ -312,10 +397,13 @@ function read_due_input(): ?string
|
|||||||
/**
|
/**
|
||||||
* 首页的「往年今日」:往年**同一天**(月与日都相同)看过的电影、走过的旅程。
|
* 首页的「往年今日」:往年**同一天**(月与日都相同)看过的电影、走过的旅程。
|
||||||
*
|
*
|
||||||
* ⚠️ 同一天太窄时会一条都没有(尤其刚用不久),所以**退回同月**:
|
* ⚠️ v24(用户 2026-10-02 第 19 条):原来同一天没有命中时会**退回同一个月**,
|
||||||
* 先给"就是这天",不够再补"这个月"的往年记录。标题由调用方按实际命中改写。
|
* 标题随之变成"往年这个月" —— 但用户要的就是**往年的今天**:
|
||||||
|
* "10 月 2 日"给我看"去年 10 月 8 日去看了场电影",这根本不是同一天的事,
|
||||||
|
* 凑出来的条目看着像回忆,其实只是同月的随机噪声,反而把"往年今日"的意味毁了。
|
||||||
|
* 现在只认同一天;没命中就明说"往年的这一天还没有记录"。
|
||||||
*
|
*
|
||||||
* @return array{rows:array,exact:bool} exact=true 表示命中的真是"同一天"
|
* @return array{rows:array,exact:bool} exact 恒为 true(保留字段只为模板兼容)
|
||||||
*/
|
*/
|
||||||
function home_memories(int $familyId, int $limit = 6): array
|
function home_memories(int $familyId, int $limit = 6): array
|
||||||
{
|
{
|
||||||
@@ -326,24 +414,17 @@ function home_memories(int $familyId, int $limit = 6): array
|
|||||||
FROM events e
|
FROM events e
|
||||||
WHERE e.family_id = ? AND e.kind IN ('movie','trip') AND e.starts_at IS NOT NULL
|
WHERE e.family_id = ? AND e.kind IN ('movie','trip') AND e.starts_at IS NOT NULL
|
||||||
AND YEAR(e.starts_at) < ?
|
AND YEAR(e.starts_at) < ?
|
||||||
AND DATE_FORMAT(e.starts_at, '%s') = ?
|
AND DATE_FORMAT(e.starts_at, '%m-%d') = ?
|
||||||
ORDER BY e.starts_at DESC LIMIT %d";
|
ORDER BY e.starts_at DESC LIMIT " . (int)$limit;
|
||||||
$st = Database::get()->prepare(sprintf($sql, '%m-%d', $limit));
|
$st = Database::get()->prepare($sql);
|
||||||
$st->execute([$familyId, $year, substr($today, 5, 5)]);
|
$st->execute([$familyId, $year, substr($today, 5, 5)]);
|
||||||
$rows = $st->fetchAll();
|
$rows = $st->fetchAll();
|
||||||
$exact = true;
|
|
||||||
if (!$rows) {
|
|
||||||
$st = Database::get()->prepare(sprintf($sql, '%m', $limit));
|
|
||||||
$st->execute([$familyId, $year, substr($today, 5, 2)]);
|
|
||||||
$rows = $st->fetchAll();
|
|
||||||
$exact = false;
|
|
||||||
}
|
|
||||||
foreach ($rows as &$r) {
|
foreach ($rows as &$r) {
|
||||||
$r['years_ago'] = $year - (int)substr((string)$r['starts_at'], 0, 4);
|
$r['years_ago'] = $year - (int)substr((string)$r['starts_at'], 0, 4);
|
||||||
$r['href'] = url($r['kind'] === 'trip' ? 'trips/show' : 'movies/show', ['id' => (int)$r['id']]);
|
$r['href'] = url($r['kind'] === 'trip' ? 'trips/show' : 'movies/show', ['id' => (int)$r['id']]);
|
||||||
}
|
}
|
||||||
unset($r);
|
unset($r);
|
||||||
return ['rows' => $rows, 'exact' => $exact];
|
return ['rows' => $rows, 'exact' => true];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 只认本家庭、确实存在的 person_id(表单可以改,不能凭它塞进别人家的人) */
|
/** 只认本家庭、确实存在的 person_id(表单可以改,不能凭它塞进别人家的人) */
|
||||||
@@ -502,9 +583,16 @@ switch ($r) {
|
|||||||
// ⚠️ v15:家谱模板不再吃 Kinship::tree() 的产物(那套 units/generations 是给
|
// ⚠️ v15:家谱模板不再吃 Kinship::tree() 的产物(那套 units/generations 是给
|
||||||
// 旧画法用的),改由模板里调 Kinship::familyMap() 现算。这里就不再白算一遍。
|
// 旧画法用的),改由模板里调 Kinship::familyMap() 现算。这里就不再白算一遍。
|
||||||
|
|
||||||
|
// v24 第 12 条:可以按某个人为中心重画(姻亲不在主图里时点卡片上的入口进来)。
|
||||||
|
// ⚠️ 「看的是谁的家谱」与「我登录着」是两件事:$meId 决定称呼视角,$focusId 决定画谁的树。
|
||||||
|
$focusId = (int)($_GET['focus'] ?? 0);
|
||||||
|
if ($focusId <= 0 || !isset($people[$focusId])) {
|
||||||
|
$focusId = $me;
|
||||||
|
}
|
||||||
View::render('kin/tree', [
|
View::render('kin/tree', [
|
||||||
'title' => '我的家谱',
|
'title' => $focusId === $me ? '我的家谱' : ((string)($people[$focusId]['name'] ?? '') . ' 的家谱'),
|
||||||
'meId' => $me,
|
'meId' => $me,
|
||||||
|
'focusId' => $focusId,
|
||||||
'people' => $people,
|
'people' => $people,
|
||||||
'graph' => $graph,
|
'graph' => $graph,
|
||||||
], 'kin');
|
], 'kin');
|
||||||
@@ -2415,25 +2503,245 @@ switch ($r) {
|
|||||||
// 自定义日程落在 calendar_events;**观影与旅行两本是派生的**(从 events 实时汇总,
|
// 自定义日程落在 calendar_events;**观影与旅行两本是派生的**(从 events 实时汇总,
|
||||||
// 不复制),给这两本"加日程"= 真的建一条观影 / 旅程记录。
|
// 不复制),给这两本"加日程"= 真的建一条观影 / 旅程记录。
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 财务(v24 第 15 条):账本 + 账目 + 共享。
|
||||||
|
*
|
||||||
|
* 用户原话要的是「一个可以共享的家庭总账」:先有账本,账本共享给某人,
|
||||||
|
* 双方都能往里记账、都能看到全部账目,最后算出总数。
|
||||||
|
* ⚠️ 金额一律以**分**存取(见 Ledger::toCents 的注释),显示时再转元。
|
||||||
|
*/
|
||||||
|
case 'ledger':
|
||||||
|
$familyId = (int)Auth::familyId();
|
||||||
|
$me = (int)Auth::personId();
|
||||||
|
$books = Ledger::mine($familyId, $me);
|
||||||
|
// 一本都没有 → 自动建一本(不然新用户进来一片空白,也不知道要先"创建家庭")
|
||||||
|
if (!$books) {
|
||||||
|
Ledger::ensureDefault($familyId, $me);
|
||||||
|
$books = Ledger::mine($familyId, $me);
|
||||||
|
}
|
||||||
|
$lid = (int)($_GET['book'] ?? 0);
|
||||||
|
$cur = null;
|
||||||
|
foreach ($books as $b) {
|
||||||
|
if ($lid > 0 && (int)$b['id'] === $lid) {
|
||||||
|
$cur = $b;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!$cur) {
|
||||||
|
$cur = $books[0];
|
||||||
|
}
|
||||||
|
$entries = Ledger::entries((int)$cur['id']);
|
||||||
|
View::render('ledger/index', [
|
||||||
|
'title' => '财务',
|
||||||
|
'books' => $books,
|
||||||
|
'cur' => $cur,
|
||||||
|
'entries' => $entries,
|
||||||
|
'summary' => Ledger::summary($entries),
|
||||||
|
'shares' => Ledger::shares((int)$cur['id']),
|
||||||
|
'people' => Person::roster($familyId, $me, 'member', ''),
|
||||||
|
], 'ledger');
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'ledger/book_add':
|
||||||
|
case 'ledger/book_rename':
|
||||||
|
case 'ledger/book_del':
|
||||||
|
case 'ledger/entry_add':
|
||||||
|
case 'ledger/entry_update':
|
||||||
|
case 'ledger/entry_del':
|
||||||
|
case 'ledger/share':
|
||||||
|
case 'ledger/unshare':
|
||||||
|
$familyId = (int)Auth::familyId();
|
||||||
|
$me = (int)Auth::personId();
|
||||||
|
Auth::requireWrite();
|
||||||
|
csrf_check();
|
||||||
|
$lid = (int)($_POST['ledger_id'] ?? 0);
|
||||||
|
$act = (string)($_GET['r'] ?? '');
|
||||||
|
$book = $lid > 0 ? Ledger::find($familyId, $lid, $me) : null;
|
||||||
|
|
||||||
|
if ($act === 'ledger/book_add') {
|
||||||
|
$id = Ledger::create($familyId, $me, (string)($_POST['name'] ?? ''));
|
||||||
|
flash('ok', '账本已创建。');
|
||||||
|
redirect('ledger', ['book' => $id]);
|
||||||
|
}
|
||||||
|
if (!$book) {
|
||||||
|
flash('err', '找不到这本账。');
|
||||||
|
redirect('ledger');
|
||||||
|
}
|
||||||
|
if (!Ledger::canWrite($book)) {
|
||||||
|
flash('err', '这本账你只有只读权限。');
|
||||||
|
redirect('ledger', ['book' => $lid]);
|
||||||
|
}
|
||||||
|
if ($act === 'ledger/book_rename') {
|
||||||
|
Ledger::rename($familyId, $lid, (string)($_POST['name'] ?? ''));
|
||||||
|
flash('ok', '账本已改名。');
|
||||||
|
} elseif ($act === 'ledger/book_del') {
|
||||||
|
Ledger::remove($familyId, $lid);
|
||||||
|
flash('ok', '账本已删除。');
|
||||||
|
redirect('ledger');
|
||||||
|
} elseif ($act === 'ledger/entry_add') {
|
||||||
|
Ledger::addEntry($lid, [
|
||||||
|
'kind' => (string)($_POST['kind'] ?? 'other'),
|
||||||
|
'title' => (string)($_POST['title'] ?? ''),
|
||||||
|
'amount' => (string)($_POST['amount'] ?? ''),
|
||||||
|
'note' => (string)($_POST['note'] ?? ''),
|
||||||
|
'person_id' => (int)($_POST['person_id'] ?? 0),
|
||||||
|
], $me);
|
||||||
|
flash('ok', '账目已添加。');
|
||||||
|
} elseif ($act === 'ledger/entry_update') {
|
||||||
|
Ledger::updateEntry($lid, (int)($_POST['id'] ?? 0), [
|
||||||
|
'kind' => (string)($_POST['kind'] ?? 'other'),
|
||||||
|
'title' => (string)($_POST['title'] ?? ''),
|
||||||
|
'amount' => (string)($_POST['amount'] ?? ''),
|
||||||
|
'note' => (string)($_POST['note'] ?? ''),
|
||||||
|
'person_id' => (int)($_POST['person_id'] ?? 0),
|
||||||
|
]);
|
||||||
|
flash('ok', '账目已保存。');
|
||||||
|
} elseif ($act === 'ledger/entry_del') {
|
||||||
|
Ledger::removeEntry($lid, (int)($_POST['id'] ?? 0));
|
||||||
|
flash('ok', '账目已删除。');
|
||||||
|
} elseif ($act === 'ledger/share') {
|
||||||
|
$pid = (int)($_POST['person_id'] ?? 0);
|
||||||
|
if ($pid > 0 && isset(Person::map($familyId)[$pid])) {
|
||||||
|
Ledger::setShare($lid, $pid, (string)($_POST['perm'] ?? 'read'));
|
||||||
|
flash('ok', '已共享。');
|
||||||
|
} else {
|
||||||
|
flash('err', '请选择要共享给的人。');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Ledger::unshare($lid, (int)($_POST['person_id'] ?? 0));
|
||||||
|
flash('ok', '已取消共享。');
|
||||||
|
}
|
||||||
|
redirect('ledger', ['book' => $lid]);
|
||||||
|
break;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 纪念日(v24 第 13 条):阴历/阳历都行;名册里每个人的生日默认可见
|
||||||
|
* (生日本身就是纪念日的一种,且**实时派生**、不复制一份)。
|
||||||
|
* 所有纪念日都会作为**全天日程**出现在日历里(见 Calendar::month 那段)。
|
||||||
|
*/
|
||||||
|
case 'days':
|
||||||
|
$familyId = (int)Auth::familyId();
|
||||||
|
$me = (int)Auth::personId();
|
||||||
|
View::render('days/index', [
|
||||||
|
'title' => '纪念日',
|
||||||
|
'rows' => Anniversary::all($familyId, $me),
|
||||||
|
'upcoming' => Anniversary::upcoming($familyId, $me, 60),
|
||||||
|
'people' => Person::roster($familyId, $me, 'member', ''),
|
||||||
|
], 'days');
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'days/new':
|
||||||
|
case 'days/edit':
|
||||||
|
$familyId = (int)Auth::familyId();
|
||||||
|
$me = (int)Auth::personId();
|
||||||
|
$id = (int)($_GET['id'] ?? 0);
|
||||||
|
$editing = $id > 0 ? Anniversary::find($familyId, $id) : null;
|
||||||
|
if ($id > 0 && !$editing) {
|
||||||
|
flash('err', '找不到这条纪念日。');
|
||||||
|
redirect('days');
|
||||||
|
}
|
||||||
|
View::render('days/form', [
|
||||||
|
'title' => $id > 0 ? '编辑纪念日' : '新建纪念日',
|
||||||
|
'editing' => $editing,
|
||||||
|
'people' => Person::roster($familyId, $me, 'member', ''),
|
||||||
|
], 'days');
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'days/save':
|
||||||
|
$familyId = (int)Auth::familyId();
|
||||||
|
Auth::requireWrite();
|
||||||
|
csrf_check();
|
||||||
|
$id = (int)($_POST['id'] ?? 0);
|
||||||
|
Anniversary::save($familyId, [
|
||||||
|
'title' => (string)($_POST['title'] ?? ''),
|
||||||
|
'repeat_type' => (string)($_POST['repeat_type'] ?? 'solar'),
|
||||||
|
'month' => (int)($_POST['month'] ?? 1),
|
||||||
|
'day' => (int)($_POST['day'] ?? 1),
|
||||||
|
'is_leap' => !empty($_POST['is_leap']),
|
||||||
|
'kind' => (string)($_POST['kind'] ?? ''),
|
||||||
|
'note' => (string)($_POST['note'] ?? ''),
|
||||||
|
'person_id' => (int)($_POST['person_id'] ?? 0),
|
||||||
|
], $id);
|
||||||
|
flash('ok', $id > 0 ? '纪念日已保存。' : '纪念日已添加。');
|
||||||
|
redirect('days');
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'days/destroy':
|
||||||
|
$familyId = (int)Auth::familyId();
|
||||||
|
Auth::requireWrite();
|
||||||
|
csrf_check();
|
||||||
|
Anniversary::remove($familyId, (int)($_POST['id'] ?? 0));
|
||||||
|
flash('ok', '纪念日已删除。');
|
||||||
|
redirect('days');
|
||||||
|
break;
|
||||||
|
|
||||||
case 'calendar':
|
case 'calendar':
|
||||||
$familyId = (int)Auth::familyId();
|
$familyId = (int)Auth::familyId();
|
||||||
$me = (int)Auth::personId();
|
$me = (int)Auth::personId();
|
||||||
$ym = trim((string)($_GET['ym'] ?? ''));
|
// v24 第 4 条:月 / 周 / 天三种看法。view 只认这三个,认不出就回落月视图。
|
||||||
if (!preg_match('/^\d{4}-\d{2}$/', $ym)) {
|
$view = (string)($_GET['view'] ?? 'month');
|
||||||
$ym = date('Y-m');
|
if (!in_array($view, ['month', 'week', 'day'], true)) {
|
||||||
|
$view = 'month';
|
||||||
}
|
}
|
||||||
|
$anchor = trim((string)($_GET['d'] ?? ''));
|
||||||
|
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $anchor)) {
|
||||||
|
$anchor = date('Y-m-d');
|
||||||
|
}
|
||||||
|
$ym = substr($anchor, 0, 7);
|
||||||
$cals = Calendar::mine($familyId, $me);
|
$cals = Calendar::mine($familyId, $me);
|
||||||
|
|
||||||
|
if ($view === 'month') {
|
||||||
|
$gym = trim((string)($_GET['ym'] ?? ''));
|
||||||
|
if (preg_match('/^\d{4}-\d{2}$/', $gym)) {
|
||||||
|
$ym = $gym;
|
||||||
|
}
|
||||||
[$from, $to] = month_grid_range($ym);
|
[$from, $to] = month_grid_range($ym);
|
||||||
$events = Calendar::month($familyId, $cals, $me, $from, $to);
|
$events = Calendar::month($familyId, $cals, $me, $from, $to);
|
||||||
|
$grid = build_month_grid($ym, $events);
|
||||||
|
} else {
|
||||||
|
// 周视图 7 列、天视图 1 列,共用同一个时间网格
|
||||||
|
$cols = day_columns($anchor, $view === 'week' ? 7 : 1);
|
||||||
|
$from = $cols[0]['date'];
|
||||||
|
$to = $cols[count($cols) - 1]['date'];
|
||||||
|
$events = Calendar::month($familyId, $cals, $me, $from, $to);
|
||||||
|
$grid = build_time_grid($events, $cols);
|
||||||
|
}
|
||||||
|
|
||||||
View::render('calendar/index', [
|
View::render('calendar/index', [
|
||||||
'title' => '日程',
|
'title' => '日程',
|
||||||
'ym' => $ym,
|
'ym' => $ym,
|
||||||
'weeks' => build_month_grid($ym, $events),
|
'view' => $view,
|
||||||
|
'anchor' => $anchor,
|
||||||
|
'cols' => $view === 'month' ? [] : ($cols ?? []),
|
||||||
|
'weeks' => $view === 'month' ? $grid : [],
|
||||||
|
'tgrid' => $view === 'month' ? null : $grid,
|
||||||
'cals' => $cals,
|
'cals' => $cals,
|
||||||
'people' => Person::roster($familyId, $me, 'member', ''),
|
'people' => Person::roster($familyId, $me, 'member', ''),
|
||||||
], 'calendar');
|
], 'calendar');
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CalDAV 专用口令(v24 第 7 条后半):生成 / 重置 / 撤销。
|
||||||
|
*
|
||||||
|
* ⚠️ 明文口令只在这**一次**跳转里带回页面显示(存 $_SESSION 一次性取用),
|
||||||
|
* 库里只有 sha256。不这么做的话,要么明文落库(一旦泄露全暴露),
|
||||||
|
* 要么用户永远看不到自己刚设的花口令。
|
||||||
|
*/
|
||||||
|
case 'calendar/caldav_token':
|
||||||
|
case 'calendar/caldav_revoke':
|
||||||
|
$me = (int)Auth::personId();
|
||||||
|
Auth::requireWrite();
|
||||||
|
csrf_check();
|
||||||
|
if ($r === 'calendar/caldav_token') {
|
||||||
|
$_SESSION['caldav_plain'] = Auth::caldavIssue($me);
|
||||||
|
flash('ok', 'CalDAV 口令已生成(旧口令立即失效,请在客户端里更新)。');
|
||||||
|
} else {
|
||||||
|
Auth::caldavRevoke($me);
|
||||||
|
flash('ok', 'CalDAV 口令已撤销,所有已订阅的客户端会失去访问。');
|
||||||
|
}
|
||||||
|
redirect('calendar/calendars');
|
||||||
|
break;
|
||||||
|
|
||||||
case 'calendar/calendars':
|
case 'calendar/calendars':
|
||||||
$familyId = (int)Auth::familyId();
|
$familyId = (int)Auth::familyId();
|
||||||
$me = (int)Auth::personId();
|
$me = (int)Auth::personId();
|
||||||
@@ -2451,6 +2759,10 @@ switch ($r) {
|
|||||||
foreach ($mine as $c) {
|
foreach ($mine as $c) {
|
||||||
$shares[(int)$c['id']] = Calendar::shares((int)$c['id']);
|
$shares[(int)$c['id']] = Calendar::shares((int)$c['id']);
|
||||||
}
|
}
|
||||||
|
// v24 第 7 条后半:CalDAV 专用口令的状态(明文只在刚生成那一次给用户看)
|
||||||
|
$cdHas = Auth::hasCalDav($me);
|
||||||
|
$cdPlain = (string)($_SESSION['caldav_plain'] ?? '');
|
||||||
|
unset($_SESSION['caldav_plain']);
|
||||||
$editing = null;
|
$editing = null;
|
||||||
$editId = (int)($_GET['edit'] ?? 0);
|
$editId = (int)($_GET['edit'] ?? 0);
|
||||||
if ($editId > 0) {
|
if ($editId > 0) {
|
||||||
@@ -2467,6 +2779,8 @@ switch ($r) {
|
|||||||
'shares' => $shares,
|
'shares' => $shares,
|
||||||
'editing' => $editing,
|
'editing' => $editing,
|
||||||
'people' => Person::roster($familyId, $me, 'member', ''),
|
'people' => Person::roster($familyId, $me, 'member', ''),
|
||||||
|
'cdHas' => $cdHas,
|
||||||
|
'cdPlain' => $cdPlain,
|
||||||
], 'calendar');
|
], 'calendar');
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@@ -2553,43 +2867,39 @@ switch ($r) {
|
|||||||
$ym = trim((string)($_POST['ym'] ?? ''));
|
$ym = trim((string)($_POST['ym'] ?? ''));
|
||||||
$ym = preg_match('/^\d{4}-\d{2}$/', $ym) ? $ym : date('Y-m');
|
$ym = preg_match('/^\d{4}-\d{2}$/', $ym) ? $ym : date('Y-m');
|
||||||
$calId = (int)($_POST['calendar_id'] ?? 0);
|
$calId = (int)($_POST['calendar_id'] ?? 0);
|
||||||
$cal = null;
|
// ⚠️ 走 mineOne 而不是 Calendar::find():要带 perm,共享本的可读写才判得对
|
||||||
foreach (Calendar::mine($familyId, $me) as $c) {
|
$cal = Calendar::mineOne($familyId, $me, $calId);
|
||||||
if ((int)$c['id'] === $calId) {
|
|
||||||
$cal = $c;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!$cal || !Calendar::canWrite($cal, $me)) {
|
if (!$cal || !Calendar::canWrite($cal, $me)) {
|
||||||
flash('err', '这本日历你没有写权限。');
|
flash('err', '这本日历你没有写权限。');
|
||||||
redirect('calendar', ['ym' => $ym]);
|
redirect('calendar', ['ym' => $ym]);
|
||||||
}
|
}
|
||||||
$date = trim((string)($_POST['date'] ?? ''));
|
// v24 第 5、6 条:起止各是一个「日期+时分秒」的完整时刻(_datetime.php 控件),
|
||||||
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
|
// 不再拆成 date / end_date / start_time / end_time 四格。
|
||||||
flash('err', '日期不对。');
|
$startsAt = read_dt_input('starts_at');
|
||||||
|
if ($startsAt === null) {
|
||||||
|
flash('err', '开始时间不对。');
|
||||||
redirect('calendar', ['ym' => $ym]);
|
redirect('calendar', ['ym' => $ym]);
|
||||||
}
|
}
|
||||||
$endDate = trim((string)($_POST['end_date'] ?? ''));
|
$endsAt = read_dt_input('ends_at');
|
||||||
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $endDate)) {
|
|
||||||
$endDate = null;
|
|
||||||
}
|
|
||||||
$title = trim((string)($_POST['title'] ?? ''));
|
$title = trim((string)($_POST['title'] ?? ''));
|
||||||
if ($title === '') {
|
if ($title === '') {
|
||||||
flash('err', '日程得有个标题。');
|
flash('err', '日程得有个标题。');
|
||||||
redirect('calendar', ['ym' => $ym]);
|
redirect('calendar', ['ym' => $ym]);
|
||||||
}
|
}
|
||||||
$allDay = !empty($_POST['all_day']);
|
$allDay = !empty($_POST['all_day']);
|
||||||
$st = $allDay ? '' : (string)($_POST['start_time'] ?? '');
|
if ($allDay) {
|
||||||
$et = $allDay ? '' : (string)($_POST['end_time'] ?? '');
|
// 全天日程:时刻部分一律归零(跨天的全天日程截止时刻含到当天末尾)
|
||||||
|
$startsAt = substr($startsAt, 0, 10) . ' 00:00:00';
|
||||||
|
$endsAt = $endsAt !== null ? substr($endsAt, 0, 10) . ' 00:00:00' : null;
|
||||||
|
}
|
||||||
$note = trim((string)($_POST['note'] ?? ''));
|
$note = trim((string)($_POST['note'] ?? ''));
|
||||||
|
|
||||||
// ⚠️ 观影 / 旅行两本是派生的:往这两本里加日程 = 建一条真正的观影 / 旅程记录,
|
// ⚠️ 观影 / 旅行两本是派生的:往这两本里加日程 = 建一条真正的观影 / 旅程记录,
|
||||||
// 而不是往 calendar_events 里塞一行(那样日历里有、观影列表里没有)。
|
// 而不是往 calendar_events 里塞一行(那样日历里有、观影列表里没有)。
|
||||||
$starts = $date . ' ' . ($st !== '' && preg_match('/^\d{2}:\d{2}$/', $st) ? $st . ':00' : '00:00:00');
|
|
||||||
$ends = ($endDate ?: $date) . ' ' . ($et !== '' && preg_match('/^\d{2}:\d{2}$/', $et) ? $et . ':00' : '00:00:00');
|
|
||||||
$kind = (string)$cal['kind'];
|
$kind = (string)$cal['kind'];
|
||||||
if ($kind === 'movie') {
|
if ($kind === 'movie') {
|
||||||
$newId = Movie::create($familyId, $me, [
|
$newId = Movie::create($familyId, $me, [
|
||||||
'title' => $title, 'starts_at' => $starts, 'ends_at' => $ends, 'note' => $note,
|
'title' => $title, 'starts_at' => $startsAt, 'ends_at' => $endsAt, 'note' => $note,
|
||||||
'attendees' => [$me],
|
'attendees' => [$me],
|
||||||
]);
|
]);
|
||||||
flash('ok', '已加进观影,日历里也能看到了。');
|
flash('ok', '已加进观影,日历里也能看到了。');
|
||||||
@@ -2597,24 +2907,30 @@ switch ($r) {
|
|||||||
}
|
}
|
||||||
if ($kind === 'trip') {
|
if ($kind === 'trip') {
|
||||||
$newId = Trip::create($familyId, $me, [
|
$newId = Trip::create($familyId, $me, [
|
||||||
'title' => $title, 'starts_at' => $starts, 'ends_at' => $ends, 'note' => $note,
|
'title' => $title, 'starts_at' => $startsAt, 'ends_at' => $endsAt, 'note' => $note,
|
||||||
'attendees' => [$me],
|
'attendees' => [$me],
|
||||||
]);
|
]);
|
||||||
flash('ok', '已加进旅程,日历里也能看到了。');
|
flash('ok', '已加进旅程,日历里也能看到了。');
|
||||||
redirect('trips/show', ['id' => $newId]);
|
redirect('trips/show', ['id' => $newId]);
|
||||||
}
|
}
|
||||||
Calendar::addEvent((int)$cal['id'], [
|
$evId = Calendar::addEvent((int)$cal['id'], [
|
||||||
'title' => $title,
|
'title' => $title,
|
||||||
'starts_at' => $date,
|
'location' => trim((string)($_POST['location'] ?? '')),
|
||||||
'ends_at' => $endDate,
|
'starts_at' => $startsAt,
|
||||||
'start_time' => $st !== '' ? $st . ':00' : null,
|
'ends_at' => $endsAt,
|
||||||
'end_time' => $et !== '' ? $et . ':00' : null,
|
|
||||||
'all_day' => $allDay,
|
'all_day' => $allDay,
|
||||||
|
'reminder_minutes' => (string)($_POST['reminder_minutes'] ?? ''),
|
||||||
|
'status' => (string)($_POST['status'] ?? 'confirmed'),
|
||||||
|
'transp' => !empty($_POST['transp']),
|
||||||
'note' => $note,
|
'note' => $note,
|
||||||
'created_by' => $me,
|
'created_by' => $me,
|
||||||
]);
|
]);
|
||||||
|
$warns = attach_save_files($familyId, Calendar::ATTACH, $evId, 'attach', $me);
|
||||||
|
if ($warns) {
|
||||||
|
flash('err', '有附件没有保存:' . implode(';', $warns));
|
||||||
|
}
|
||||||
flash('ok', '日程已加上。');
|
flash('ok', '日程已加上。');
|
||||||
redirect('calendar', ['ym' => substr($date, 0, 7)]);
|
redirect('calendar', ['ym' => substr($startsAt, 0, 7)]);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'calendar/event':
|
case 'calendar/event':
|
||||||
@@ -2632,8 +2948,7 @@ switch ($r) {
|
|||||||
flash('err', '找不到这条日程。');
|
flash('err', '找不到这条日程。');
|
||||||
redirect('calendar');
|
redirect('calendar');
|
||||||
}
|
}
|
||||||
$cal = Calendar::find((int)$ev['calendar_id']) ?: [];
|
$ev['_can_write'] = Calendar::canWriteEvent($ev, $me);
|
||||||
$ev['_can_write'] = Calendar::canWrite($cal, $me);
|
|
||||||
View::render('calendar/event', [
|
View::render('calendar/event', [
|
||||||
'title' => (string)$ev['title'],
|
'title' => (string)$ev['title'],
|
||||||
'ev' => $ev,
|
'ev' => $ev,
|
||||||
@@ -2660,31 +2975,40 @@ switch ($r) {
|
|||||||
flash('err', '找不到这条日程。');
|
flash('err', '找不到这条日程。');
|
||||||
redirect('calendar', ['ym' => $ym]);
|
redirect('calendar', ['ym' => $ym]);
|
||||||
}
|
}
|
||||||
$cal = Calendar::find((int)$ev['calendar_id']) ?: [];
|
$cal = Calendar::mineOne($familyId, $me, (int)$ev['calendar_id']) ?: [];
|
||||||
if (!Calendar::canWrite($cal, $me)) {
|
if (!Calendar::canWrite($cal, $me)) {
|
||||||
flash('err', '这本日历你没有写权限。');
|
flash('err', '这本日历你没有写权限。');
|
||||||
redirect('calendar', ['ym' => $ym]);
|
redirect('calendar', ['ym' => $ym]);
|
||||||
}
|
}
|
||||||
$date = trim((string)($_POST['date'] ?? ''));
|
// v24 第 5、6 条:起止各是一个完整时刻(精确到秒),见上面 event_add 的说明。
|
||||||
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
|
$startsAt = read_dt_input('starts_at');
|
||||||
flash('err', '日期不对。');
|
if ($startsAt === null) {
|
||||||
|
flash('err', '开始时间不对。');
|
||||||
redirect('calendar', ['ym' => $ym]);
|
redirect('calendar', ['ym' => $ym]);
|
||||||
}
|
}
|
||||||
|
$endsAt = read_dt_input('ends_at');
|
||||||
$allDay = !empty($_POST['all_day']);
|
$allDay = !empty($_POST['all_day']);
|
||||||
$endDate = trim((string)($_POST['end_date'] ?? ''));
|
if ($allDay) {
|
||||||
|
$startsAt = substr($startsAt, 0, 10) . ' 00:00:00';
|
||||||
|
$endsAt = $endsAt !== null ? substr($endsAt, 0, 10) . ' 00:00:00' : null;
|
||||||
|
}
|
||||||
Calendar::updateEvent($id, [
|
Calendar::updateEvent($id, [
|
||||||
'title' => trim((string)($_POST['title'] ?? '')),
|
'title' => trim((string)($_POST['title'] ?? '')),
|
||||||
'starts_at' => $date,
|
'location' => trim((string)($_POST['location'] ?? '')),
|
||||||
'ends_at' => preg_match('/^\d{4}-\d{2}-\d{2}$/', $endDate) ? $endDate : null,
|
'starts_at' => $startsAt,
|
||||||
'start_time' => !$allDay && preg_match('/^\d{2}:\d{2}$/', (string)($_POST['start_time'] ?? ''))
|
'ends_at' => $endsAt,
|
||||||
? (string)$_POST['start_time'] . ':00' : null,
|
|
||||||
'end_time' => !$allDay && preg_match('/^\d{2}:\d{2}$/', (string)($_POST['end_time'] ?? ''))
|
|
||||||
? (string)$_POST['end_time'] . ':00' : null,
|
|
||||||
'all_day' => $allDay,
|
'all_day' => $allDay,
|
||||||
|
'reminder_minutes' => (string)($_POST['reminder_minutes'] ?? ''),
|
||||||
|
'status' => (string)($_POST['status'] ?? 'confirmed'),
|
||||||
|
'transp' => !empty($_POST['transp']),
|
||||||
'note' => trim((string)($_POST['note'] ?? '')),
|
'note' => trim((string)($_POST['note'] ?? '')),
|
||||||
]);
|
]);
|
||||||
|
$warns = attach_save_files($familyId, Calendar::ATTACH, $id, 'attach', $me);
|
||||||
|
if ($warns) {
|
||||||
|
flash('err', '有附件没有保存:' . implode(';', $warns));
|
||||||
|
}
|
||||||
flash('ok', '日程已保存。');
|
flash('ok', '日程已保存。');
|
||||||
redirect('calendar', ['ym' => substr($date, 0, 7)]);
|
redirect('calendar', ['ym' => substr($startsAt, 0, 7)]);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'calendar/event_del':
|
case 'calendar/event_del':
|
||||||
@@ -2703,7 +3027,7 @@ switch ($r) {
|
|||||||
$st->execute([$id, $familyId]);
|
$st->execute([$id, $familyId]);
|
||||||
$ev = $st->fetch();
|
$ev = $st->fetch();
|
||||||
if ($ev) {
|
if ($ev) {
|
||||||
$cal = Calendar::find((int)$ev['calendar_id']) ?: [];
|
$cal = Calendar::mineOne($familyId, $me, (int)$ev['calendar_id']) ?: [];
|
||||||
if (Calendar::canWrite($cal, $me)) {
|
if (Calendar::canWrite($cal, $me)) {
|
||||||
Calendar::deleteEvent($id);
|
Calendar::deleteEvent($id);
|
||||||
flash('ok', '日程已删除。');
|
flash('ok', '日程已删除。');
|
||||||
@@ -2714,6 +3038,98 @@ switch ($r) {
|
|||||||
redirect('calendar', ['ym' => $ym]);
|
redirect('calendar', ['ym' => $ym]);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
// ==================== ics 导出 / 导入(v24 第 7 条)====================
|
||||||
|
case 'calendar/export':
|
||||||
|
$familyId = (int)Auth::familyId();
|
||||||
|
$me = (int)Auth::personId();
|
||||||
|
$calId = (int)($_GET['id'] ?? 0);
|
||||||
|
$cal = Calendar::mineOne($familyId, $me, $calId);
|
||||||
|
if (!$cal) {
|
||||||
|
flash('err', '找不到这本日历。');
|
||||||
|
redirect('calendar');
|
||||||
|
}
|
||||||
|
$rows = Calendar::exportRows($cal, $familyId, $me);
|
||||||
|
$ics = Ics::export($rows, (string)$cal['name']);
|
||||||
|
// ⚠️ 文件名要用**本子名 + 日期**,别用中文之外的怪字符:
|
||||||
|
// Content-Disposition 的非 ASCII 文件名各家浏览器编码方式不一样,
|
||||||
|
// 最稳的做法是给一个 ASCII 兜底名(filename=)再给一个 UTF-8 的(filename*=)。
|
||||||
|
$ascii = 'calendar-' . (int)$cal['id'] . '-' . date('Ymd') . '.ics';
|
||||||
|
$utf8 = rawurlencode((string)$cal['name'] . '-' . date('Y-m-d') . '.ics');
|
||||||
|
header('Content-Type: text/calendar; charset=utf-8');
|
||||||
|
header('Content-Disposition: attachment; filename="' . $ascii . '"; filename*=UTF-8\'\'' . $utf8);
|
||||||
|
header('Content-Length: ' . strlen($ics));
|
||||||
|
echo $ics;
|
||||||
|
exit;
|
||||||
|
|
||||||
|
case 'calendar/import':
|
||||||
|
$familyId = (int)Auth::familyId();
|
||||||
|
$me = (int)Auth::personId();
|
||||||
|
Auth::requireWrite();
|
||||||
|
csrf_check();
|
||||||
|
$calId = (int)($_POST['calendar_id'] ?? 0);
|
||||||
|
$cal = Calendar::mineOne($familyId, $me, $calId);
|
||||||
|
if (!$cal || !Calendar::canWrite($cal, $me)) {
|
||||||
|
flash('err', '这本日历你没有写权限。');
|
||||||
|
redirect('calendar/calendars');
|
||||||
|
}
|
||||||
|
// ⚠️ 只能导进**自定义本**:观影 / 旅行两本是派生的(内容是 events / trip_nodes),
|
||||||
|
// 往里塞 calendar_events 会出现"日历里有、观影列表里没有"的孤儿。
|
||||||
|
if ((string)$cal['kind'] !== 'custom') {
|
||||||
|
flash('err', '只能往自定义日历本里导入。观影、旅行两本是从记录里自动汇总的。');
|
||||||
|
redirect('calendar/calendars');
|
||||||
|
}
|
||||||
|
if (empty($_FILES['ics']) || (int)($_FILES['ics']['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
|
||||||
|
flash('err', '没收到 .ics 文件。');
|
||||||
|
redirect('calendar/calendars');
|
||||||
|
}
|
||||||
|
$text = (string)file_get_contents((string)$_FILES['ics']['tmp_name']);
|
||||||
|
$list = Ics::parse($text);
|
||||||
|
if (!$list) {
|
||||||
|
flash('err', '这个文件里没有找到任何日程(VEVENT)。');
|
||||||
|
redirect('calendar/calendars');
|
||||||
|
}
|
||||||
|
$added = 0;
|
||||||
|
$updated = 0;
|
||||||
|
foreach ($list as $v) {
|
||||||
|
if (($v['starts_at'] ?? null) === null) {
|
||||||
|
continue; // 没有 DTSTART 的 VEVENT 是坏数据,跳过而不是写成 1970 年
|
||||||
|
}
|
||||||
|
if (trim((string)$v['title']) === '') {
|
||||||
|
$v['title'] = '(无标题日程)';
|
||||||
|
}
|
||||||
|
if (Calendar::importEvent($calId, $v, $me) === 'added') {
|
||||||
|
$added++;
|
||||||
|
} else {
|
||||||
|
$updated++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flash('ok', '导入完成:新增 ' . $added . ' 条,更新 ' . $updated . ' 条。');
|
||||||
|
redirect('calendar/calendars');
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'calendar/attach_del':
|
||||||
|
$familyId = (int)Auth::familyId();
|
||||||
|
$me = (int)Auth::personId();
|
||||||
|
Auth::requireWrite();
|
||||||
|
csrf_check();
|
||||||
|
$id = (int)($_POST['id'] ?? 0);
|
||||||
|
$aid = (int)($_POST['attach_id'] ?? 0);
|
||||||
|
$ev = Calendar::event($id);
|
||||||
|
if ($ev && Calendar::canWriteEvent($ev, $me)) {
|
||||||
|
// ⚠️ 附件 id 与宿主 id 一起判:只按 attach_id 删,就能用别的日程的
|
||||||
|
// 附件 id 把人家文件删掉(宿主对不上就静默放行是很典型的越权)。
|
||||||
|
$st = Database::get()->prepare(
|
||||||
|
'SELECT id FROM attachments WHERE id = ? AND target_type = ? AND target_id = ?'
|
||||||
|
);
|
||||||
|
$st->execute([$aid, Calendar::ATTACH, $id]);
|
||||||
|
if ($st->fetchColumn() !== false) {
|
||||||
|
Attachment::remove($aid);
|
||||||
|
flash('ok', '附件已删除。');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
redirect('calendar/event', ['id' => $id]);
|
||||||
|
break;
|
||||||
|
|
||||||
// ==================== 资源(v12) ====================
|
// ==================== 资源(v12) ====================
|
||||||
// 挂在**某个人名下**的东西(证件 / 照片 / 账号),可共享给别人(只读或读写)。
|
// 挂在**某个人名下**的东西(证件 / 照片 / 账号),可共享给别人(只读或读写)。
|
||||||
|
|
||||||
@@ -2887,15 +3303,99 @@ switch ($r) {
|
|||||||
case 'tasks':
|
case 'tasks':
|
||||||
$familyId = (int)Auth::familyId();
|
$familyId = (int)Auth::familyId();
|
||||||
$me = (int)Auth::personId();
|
$me = (int)Auth::personId();
|
||||||
$board = Task::board($familyId, $me);
|
// v24 第 10 条:看板由用户自己建(家务 / 装修 / …),顶上有个切换条
|
||||||
|
$boards = Task::boards($familyId);
|
||||||
|
$bid = (int)($_GET['board'] ?? 0);
|
||||||
|
$board = Task::board($familyId, $me, $bid);
|
||||||
View::render('tasks/index', [
|
View::render('tasks/index', [
|
||||||
'title' => '任务',
|
'title' => '任务',
|
||||||
'board' => $board,
|
'board' => $board,
|
||||||
'total' => count($board['todo']) + count($board['doing']) + count($board['done']),
|
'boards' => $boards,
|
||||||
|
'curBoard' => $bid > 0 ? $bid : Task::ensureDefaultBoard($familyId),
|
||||||
|
'categories' => Task::categories($familyId),
|
||||||
|
'total' => count($board['todo']) + count($board['doing']),
|
||||||
'people' => Person::roster($familyId, $me, 'member', ''),
|
'people' => Person::roster($familyId, $me, 'member', ''),
|
||||||
], 'tasks');
|
], 'tasks');
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 任务设置(v24 第 9、10 条):分类 / 看板 / 已完成的归档。
|
||||||
|
* 入口在「新建任务」旁边那个 ☰ 下拉里,与观影的 ☰ 是同一套做法。
|
||||||
|
*/
|
||||||
|
case 'tasks/settings':
|
||||||
|
$familyId = (int)Auth::familyId();
|
||||||
|
View::render('tasks/settings', [
|
||||||
|
'title' => '任务设置',
|
||||||
|
'boards' => Task::boards($familyId),
|
||||||
|
'categories' => Task::categories($familyId),
|
||||||
|
], 'tasks');
|
||||||
|
break;
|
||||||
|
|
||||||
|
/** 已完成的任务(按分类查看 / 按关键词搜) */
|
||||||
|
case 'tasks/done':
|
||||||
|
$familyId = (int)Auth::familyId();
|
||||||
|
$me = (int)Auth::personId();
|
||||||
|
$cid = (int)($_GET['cat'] ?? 0);
|
||||||
|
$kw = trim((string)($_GET['kw'] ?? ''));
|
||||||
|
View::render('tasks/done', [
|
||||||
|
'title' => '已完成',
|
||||||
|
'rows' => Task::doneList($familyId, $me, $cid, $kw),
|
||||||
|
'categories' => Task::categories($familyId),
|
||||||
|
'cat' => $cid,
|
||||||
|
'kw' => $kw,
|
||||||
|
], 'tasks');
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'tasks/board_add':
|
||||||
|
case 'tasks/board_update':
|
||||||
|
case 'tasks/board_del':
|
||||||
|
case 'tasks/cat_add':
|
||||||
|
case 'tasks/cat_update':
|
||||||
|
case 'tasks/cat_del':
|
||||||
|
$familyId = (int)Auth::familyId();
|
||||||
|
Auth::requireWrite();
|
||||||
|
csrf_check();
|
||||||
|
$id = (int)($_POST['id'] ?? 0);
|
||||||
|
$nm = trim((string)($_POST['name'] ?? ''));
|
||||||
|
$act = (string)($_GET['r'] ?? '');
|
||||||
|
if ($act === 'tasks/board_add') {
|
||||||
|
if ($nm === '') {
|
||||||
|
flash('err', '看板名不能为空。');
|
||||||
|
} else {
|
||||||
|
Task::createBoard($familyId, $nm);
|
||||||
|
flash('ok', '看板已添加。');
|
||||||
|
}
|
||||||
|
} elseif ($act === 'tasks/board_update') {
|
||||||
|
if ($nm === '') {
|
||||||
|
flash('err', '看板名不能为空。');
|
||||||
|
} else {
|
||||||
|
Task::renameBoard($familyId, $id, $nm);
|
||||||
|
flash('ok', '看板已改名。');
|
||||||
|
}
|
||||||
|
} elseif ($act === 'tasks/board_del') {
|
||||||
|
Task::deleteBoard($familyId, $id);
|
||||||
|
flash('ok', '看板已删除,里头的任务挪到了默认看板。');
|
||||||
|
} elseif ($act === 'tasks/cat_add') {
|
||||||
|
if ($nm === '') {
|
||||||
|
flash('err', '分类名不能为空。');
|
||||||
|
} else {
|
||||||
|
Task::createCategory($familyId, $nm);
|
||||||
|
flash('ok', '分类已添加。');
|
||||||
|
}
|
||||||
|
} elseif ($act === 'tasks/cat_update') {
|
||||||
|
if ($nm === '') {
|
||||||
|
flash('err', '分类名不能为空。');
|
||||||
|
} else {
|
||||||
|
Task::renameCategory($familyId, $id, $nm);
|
||||||
|
flash('ok', '分类已改名。');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Task::deleteCategory($familyId, $id);
|
||||||
|
flash('ok', '分类已删除,用它标过的任务变成「未分类」。');
|
||||||
|
}
|
||||||
|
redirect('tasks/settings');
|
||||||
|
break;
|
||||||
|
|
||||||
case 'tasks/show':
|
case 'tasks/show':
|
||||||
$familyId = (int)Auth::familyId();
|
$familyId = (int)Auth::familyId();
|
||||||
$me = (int)Auth::personId();
|
$me = (int)Auth::personId();
|
||||||
@@ -2918,6 +3418,9 @@ switch ($r) {
|
|||||||
't' => $t,
|
't' => $t,
|
||||||
'people' => Person::roster($familyId, $me, 'member', ''),
|
'people' => Person::roster($familyId, $me, 'member', ''),
|
||||||
'parents' => $parents,
|
'parents' => $parents,
|
||||||
|
// v24 第 9、10 条:详情页的编辑表单里也能改分类与看板
|
||||||
|
'boards' => Task::boards($familyId),
|
||||||
|
'categories' => Task::categories($familyId),
|
||||||
], 'tasks');
|
], 'tasks');
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@@ -2946,6 +3449,9 @@ switch ($r) {
|
|||||||
'repeat_rule' => trim((string)($_POST['repeat_rule'] ?? '')),
|
'repeat_rule' => trim((string)($_POST['repeat_rule'] ?? '')),
|
||||||
'parent_id' => (int)($_POST['parent_id'] ?? 0),
|
'parent_id' => (int)($_POST['parent_id'] ?? 0),
|
||||||
'assignee_id' => (int)($_POST['assignee_id'] ?? 0),
|
'assignee_id' => (int)($_POST['assignee_id'] ?? 0),
|
||||||
|
// v24 第 9、10 条:分类与看板。Task 侧会校验归属(不认别的家庭的 id)。
|
||||||
|
'category_id' => (int)($_POST['category_id'] ?? 0),
|
||||||
|
'board_id' => (int)($_POST['board_id'] ?? 0),
|
||||||
'status' => $isEdit ? (string)$old['status'] : 'todo',
|
'status' => $isEdit ? (string)$old['status'] : 'todo',
|
||||||
];
|
];
|
||||||
if ($d['assignee_id'] > 0 && !isset(Person::map($familyId)[$d['assignee_id']])) {
|
if ($d['assignee_id'] > 0 && !isset(Person::map($familyId)[$d['assignee_id']])) {
|
||||||
|
|||||||
+31
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* 前端控制器(路由表)—— 只给 **PHP 内置服务器** 用(php -S ... router.php)。
|
||||||
|
*
|
||||||
|
* 为什么要它:
|
||||||
|
* · 线上是 Apache,`/dav/3.ics/` 这种没有对应文件的 URL 靠 .htaccess 重写到 dav.php;
|
||||||
|
* · 本地 `php -S` **不读 .htaccess**,所以同一个地址会被当成"文件不存在"直接 404,
|
||||||
|
* 于是「CalDAV 在本地测不了、只能上线验证」——这正是最容易上线才发现问题的写法。
|
||||||
|
* 这个文件把那几条重写规则用 PHP 重述一遍,本地与线上一模一样的路径行为。
|
||||||
|
*
|
||||||
|
* ⚠️ 里面**不写任何业务**:只决定"交给哪个脚本",所有业务仍在 index.php / dav.php / media.php。
|
||||||
|
* 线上部署不该带它(Apache 走 .htaccess),但带着也无害 —— 它不会被执行。
|
||||||
|
*/
|
||||||
|
|
||||||
|
$path = (string)parse_url((string)($_SERVER['REQUEST_URI'] ?? '/'), PHP_URL_PATH);
|
||||||
|
|
||||||
|
// CalDAV:/dav/ 及其子路径(/dav/、/dav/3.ics/、/dav/3.ics/12.ics)
|
||||||
|
if (preg_match('#^/dav(/.*)?$#', $path)) {
|
||||||
|
require __DIR__ . '/dav.php';
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 真实存在的文件(静态资源、media.php 等)→ 交给内置服务器自己处理
|
||||||
|
$file = __DIR__ . $path;
|
||||||
|
if ($path !== '/' && is_file($file)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 其余交给主路由
|
||||||
|
require __DIR__ . '/index.php';
|
||||||
|
return true;
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* 附件缩略图墙(v24 用户第 3 条)
|
||||||
|
*
|
||||||
|
* 一处渲染、全站通用:旅程节点 / 资源 / 任务 / 日程的附件都长一样。
|
||||||
|
*
|
||||||
|
* 规则(用户原话):
|
||||||
|
* · 图片、视频 → **缩略图**,点开在**本页**预览(不再新开标签页)
|
||||||
|
* · PDF → 浏览器里直接预览(自带阅读器)
|
||||||
|
* · Word / Excel / PPT / 其它 → 一个**和缩略图一样大**的图标,点了直接下载
|
||||||
|
* (浏览器渲染不了的东西,绝不装一个"点了没反应"的预览)
|
||||||
|
* · 预览框**右上角**恒有一个下载按钮
|
||||||
|
*
|
||||||
|
* 用法(先准备变量再 require):
|
||||||
|
* $attRows 附件行(Attachment::list 的结果)
|
||||||
|
* $attCanEdit 是否显示删除按钮
|
||||||
|
* $attDelUrl 删除动作的路由名(如 'resources/attach_del')
|
||||||
|
* $attHostId 删除时要回传的宿主 id(表单里的隐藏字段用 $attHostKey 指定键名)
|
||||||
|
* $attDelField 删除表单里「附件 id」的字段名(各路由不一致:有的叫 id、有的叫 attach_id)
|
||||||
|
* $attExtra 额外隐藏字段 [键 => 值](如日程要连带传宿主 id 才能判权限)
|
||||||
|
*
|
||||||
|
* ⚠️ 无 JS 也能用:可预览的是 <a href=预览地址>,不可预览的是 <a href=下载地址>,
|
||||||
|
* 脚本只是把"可预览"的那部分拦下来改成弹层。
|
||||||
|
*/
|
||||||
|
|
||||||
|
$attCanEdit = !empty($attCanEdit);
|
||||||
|
$attRows = $attRows ?? [];
|
||||||
|
$attDelField = $attDelField ?? 'id';
|
||||||
|
$attExtra = $attExtra ?? [];
|
||||||
|
$attDelUrl = $attDelUrl ?? '';
|
||||||
|
?>
|
||||||
|
<?php if ($attRows): ?>
|
||||||
|
<div class="att-wall">
|
||||||
|
<?php foreach ($attRows as $a): ?>
|
||||||
|
<?php
|
||||||
|
$kind = \App\Attachment::kind($a);
|
||||||
|
$canPrev = \App\Attachment::previewable($a);
|
||||||
|
$view = media_url((string)$a['key']);
|
||||||
|
$dl = media_url((string)$a['key'], ['dl' => 1]);
|
||||||
|
$nm = (string)($a['name'] ?? '');
|
||||||
|
$sz = \App\Attachment::sizeWord($a['size'] ?? 0);
|
||||||
|
?>
|
||||||
|
<div class="att-cell">
|
||||||
|
<?php if ($canPrev): ?>
|
||||||
|
<a class="att-thumb att-kind-<?= e($kind) ?>"
|
||||||
|
href="<?= e($view) ?>"
|
||||||
|
data-prev="<?= e($kind === 'pdf' ? 'pdf' : ($kind === 'video' ? 'video' : 'image')) ?>"
|
||||||
|
data-src="<?= e($view) ?>"
|
||||||
|
data-dl="<?= e($dl) ?>"
|
||||||
|
data-name="<?= e($nm !== '' ? $nm : \App\Attachment::kindWord($kind)) ?>"
|
||||||
|
data-size="<?= e($sz) ?>"
|
||||||
|
title="<?= e($nm) ?>">
|
||||||
|
<?php if ($kind === 'image'): ?>
|
||||||
|
<img src="<?= e($view) ?>" alt="<?= e($nm) ?>" loading="lazy">
|
||||||
|
<?php elseif ($kind === 'video'): ?>
|
||||||
|
<?php /* #t=0.1 让浏览器把第 0.1 秒那一帧当封面显示出来 ——
|
||||||
|
没有它视频位就是一块黑板,看不出这是段什么视频。 */ ?>
|
||||||
|
<video src="<?= e($view) ?>#t=0.1" preload="metadata" muted playsinline></video>
|
||||||
|
<span class="att-play" aria-hidden="true">▶</span>
|
||||||
|
<?php else: ?>
|
||||||
|
<span class="att-doc att-doc-pdf">PDF</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</a>
|
||||||
|
<?php else: ?>
|
||||||
|
<a class="att-thumb att-kind-<?= e($kind) ?>" href="<?= e($dl) ?>"
|
||||||
|
title="<?= e($nm) ?>(点击下载)">
|
||||||
|
<span class="att-doc att-doc-<?= e($kind) ?>"><?= e(\App\Attachment::kindWord($kind)) ?></span>
|
||||||
|
</a>
|
||||||
|
<?php endif; ?>
|
||||||
|
<div class="att-meta">
|
||||||
|
<span class="att-fname" title="<?= e($nm) ?>"><?= e($nm !== '' ? $nm : \App\Attachment::kindWord($kind)) ?></span>
|
||||||
|
<span class="att-fsize"><?= e($sz) ?></span>
|
||||||
|
<a class="link-quiet att-dl" href="<?= e($dl) ?>">下载</a>
|
||||||
|
<?php if ($attCanEdit && !empty($attDelUrl)): ?>
|
||||||
|
<form method="post" action="<?= e(url($attDelUrl)) ?>" class="att-del">
|
||||||
|
<?= csrf_field() ?>
|
||||||
|
<input type="hidden" name="<?= e($attDelField) ?>" value="<?= (int)$a['id'] ?>">
|
||||||
|
<?php foreach ($attExtra as $ek => $ev): ?>
|
||||||
|
<input type="hidden" name="<?= e((string)$ek) ?>" value="<?= e((string)$ev) ?>">
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<button class="btn btn-ghost btn-sm" type="submit"
|
||||||
|
onclick="return confirm('删掉这个附件?')">删除</button>
|
||||||
|
</form>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<?php /* ⚠️ 循环里用到的临时变量一律用完即清:宿主模板里很可能也有 $a / $kind /
|
||||||
|
$view / $dl / $nm / $sz(各模块页面都爱用这种短名),留着就是静默覆盖。 */ ?>
|
||||||
|
<?php unset($a, $kind, $canPrev, $view, $dl, $nm, $sz); ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
@@ -24,7 +24,13 @@ $dtRequired = (bool)($dtRequired ?? false);
|
|||||||
$dtId = (string)($dtId ?? '');
|
$dtId = (string)($dtId ?? '');
|
||||||
$dtSeconds = (bool)($dtSeconds ?? true);
|
$dtSeconds = (bool)($dtSeconds ?? true);
|
||||||
|
|
||||||
$dtTs = (!empty($dtValue) && ($t = strtotime(trim((string)$dtValue)))) ? $t : false;
|
// ⚠️⚠️ 临时变量**一律带 dt 前缀**,一个都不能漏:这里原来写成
|
||||||
|
// `$t = strtotime(...)`,把宿主模板里表示「这条任务 / 这条记录」的 `$t`
|
||||||
|
// 覆盖成了 int —— 于是 include 之后凡是读 `$t['id']`、`$t['status']` 的地方
|
||||||
|
// 全部拿到 null,子任务列表空、评论不渲染、@高亮消失,而且**不报任何错**
|
||||||
|
// (PHP 8 对 "Trying to access array offset on value of type int" 只 Warning)。
|
||||||
|
// tasks/show.php 上就是这么被 e2e 抓到的(2026-10-02)。
|
||||||
|
$dtTs = (!empty($dtValue) && ($dtTsTmp = strtotime(trim((string)$dtValue)))) ? $dtTsTmp : false;
|
||||||
$dtDate = $dtTs ? date('Y-m-d', $dtTs) : '';
|
$dtDate = $dtTs ? date('Y-m-d', $dtTs) : '';
|
||||||
$dtHour = $dtTs ? (int)date('G', $dtTs) : 0; // G = 0..23,天然 24 小时制
|
$dtHour = $dtTs ? (int)date('G', $dtTs) : 0; // G = 0..23,天然 24 小时制
|
||||||
$dtMin = $dtTs ? (int)date('i', $dtTs) : 0;
|
$dtMin = $dtTs ? (int)date('i', $dtTs) : 0;
|
||||||
@@ -62,6 +68,6 @@ $dtSec = $dtTs ? (int)date('s', $dtTs) : 0;
|
|||||||
</div>
|
</div>
|
||||||
<?php /* 用完即清,避免残留变量影响下一次 include */ unset(
|
<?php /* 用完即清,避免残留变量影响下一次 include */ unset(
|
||||||
$dtName, $dtValue, $dtRequired, $dtId, $dtSeconds,
|
$dtName, $dtValue, $dtRequired, $dtId, $dtSeconds,
|
||||||
$dtTs, $dtDate, $dtHour, $dtMin, $dtSec,
|
$dtTs, $dtTsTmp, $dtDate, $dtHour, $dtMin, $dtSec,
|
||||||
$dtH, $dtM, $dtS, $hv, $mv, $sv
|
$dtH, $dtM, $dtS, $hv, $mv, $sv
|
||||||
); ?>
|
); ?>
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* 全局人员选择器(v24 用户第 11 条)
|
||||||
|
*
|
||||||
|
* 「所有要查找人的地方」统一走这一个片段:chips + 弹窗 + 拼音首字母联想。
|
||||||
|
* 之前只有观影表单有这套(.att-*),旅程是平铺的 checkbox 网格、共享/归属是
|
||||||
|
* <select> 下拉 —— 名册一多就变成"在一长串里瞪眼找",而且三种地方三种手感。
|
||||||
|
*
|
||||||
|
* 用法(在模板里先准备好变量再 include):
|
||||||
|
* $ppName 表单字段名。多选传 'attendees[]';单选传 'owner_id' 等
|
||||||
|
* $ppPeople 候选的人(Person::roster() 的行,至少要有 id / name)
|
||||||
|
* $ppSelected 已选的 id 数组(单选传 0 或 1 个)
|
||||||
|
* $ppMode 'multi'(默认)| 'single'
|
||||||
|
* $ppOpenBtn 打开按钮文案(默认 multi=「+ 添加人」/ single=「选择」)
|
||||||
|
* $ppTitle 弹窗标题(默认「选择人」)
|
||||||
|
* $ppAllowEmpty 单选模式下是否给一个「不选」选项(筛选表单用;值为 0)
|
||||||
|
* $ppEmptyText 单选且未选时的占位(默认「未选择」)
|
||||||
|
* $ppTip 可选,渲染在按钮下面的一句话
|
||||||
|
*
|
||||||
|
* ⚠️ 无 JS 时也能用:选项就是弹窗列表里的原生 checkbox / radio,
|
||||||
|
* noscript 样式把弹窗摊平成一个普通列表,提交语义与有 JS 时完全一致。
|
||||||
|
*
|
||||||
|
* ⚠️ 同一页出现多个选择器时 $ppId 必须不同 —— 片段自己按出现顺序编号,
|
||||||
|
* 调用方不用管(但同一页重复 include 同一份变量也不会撞)。
|
||||||
|
*/
|
||||||
|
|
||||||
|
$ppMode = $ppMode ?? 'multi';
|
||||||
|
$ppPeople = $ppPeople ?? [];
|
||||||
|
$ppSelected = array_map('intval', (array)($ppSelected ?? []));
|
||||||
|
$ppOpenBtn = $ppOpenBtn ?? ($ppMode === 'single' ? '选择' : '+ 添加人');
|
||||||
|
$ppTitle = $ppTitle ?? '选择';
|
||||||
|
$ppEmptyText = $ppEmptyText ?? '未选择';
|
||||||
|
$ppAllowEmpty = !empty($ppAllowEmpty);
|
||||||
|
|
||||||
|
// 同一页多个实例要各有一套 id,否则 document.getElementById 全抓到第一个
|
||||||
|
static $ppSeq = 0;
|
||||||
|
$ppSeq++;
|
||||||
|
$ppId = 'pp' . $ppSeq;
|
||||||
|
$ppIsMulti = $ppMode === 'single' ? false : true;
|
||||||
|
$ppInputType = $ppIsMulti ? 'checkbox' : 'radio';
|
||||||
|
// ⚠️ 「已选」要按**非零**判断:筛选表单常用 0 表示「全部」,[0] 是个非空数组,
|
||||||
|
// 直接 !$ppSelected 会误判成"选了人",于是「不选」那一项反而不勾。
|
||||||
|
$ppHasSel = count(array_filter($ppSelected)) > 0;
|
||||||
|
?>
|
||||||
|
<div class="pp" id="<?= e($ppId) ?>" data-pp="<?= e($ppId) ?>" data-pp-mode="<?= e($ppIsMulti ? 'multi' : 'single') ?>">
|
||||||
|
<div class="pp-chips" id="<?= e($ppId) ?>-chips">
|
||||||
|
<?php /* ⚠️ 循环变量一律用 $ppRow,绝不叫 $p:宿主模板里 $p 常常是别的东西
|
||||||
|
(高级搜索页的 $p 是"查询条件数组"),被这个片段一覆盖,
|
||||||
|
页面后半段的 $p['xxx'] 就全成了 Warning。_datetime.php 踩过同款。 */ ?>
|
||||||
|
<?php foreach ($ppPeople as $ppRow): ?>
|
||||||
|
<?php if (in_array((int)$ppRow['id'], $ppSelected, true)): ?>
|
||||||
|
<span class="pp-chip" data-id="<?= (int)$ppRow['id'] ?>"><img src="<?= e(avatar_url($ppRow['avatar'] ?? null)) ?>" alt=""><span class="pp-name"><?= e(person_name($ppRow)) ?></span><?php if ($ppIsMulti): ?><button type="button" class="pp-x" data-id="<?= (int)$ppRow['id'] ?>" title="移除">×</button><?php endif; ?></span>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<?php if (!$ppIsMulti && !$ppHasSel): ?>
|
||||||
|
<span class="pp-chip pp-chip-empty"><?= e($ppEmptyText) ?></span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-ghost btn-sm pp-open" id="<?= e($ppId) ?>-open"><?= e($ppOpenBtn) ?></button>
|
||||||
|
<?php if (!empty($ppTip)): ?><span class="tip"><?= e($ppTip) ?></span><?php endif; ?>
|
||||||
|
<div class="pp-modal" id="<?= e($ppId) ?>-modal" hidden>
|
||||||
|
<div class="pp-modal-box">
|
||||||
|
<div class="pp-modal-head">
|
||||||
|
<b><?= e($ppTitle) ?></b>
|
||||||
|
<button type="button" class="pp-close" id="<?= e($ppId) ?>-close" title="关闭">×</button>
|
||||||
|
</div>
|
||||||
|
<input class="input pp-search" id="<?= e($ppId) ?>-search" type="text"
|
||||||
|
placeholder="输入名字或拼音首字母,如 yyq 找「杨永全」" autocomplete="off">
|
||||||
|
<div class="pp-list" id="<?= e($ppId) ?>-list">
|
||||||
|
<?php if (!$ppIsMulti && $ppAllowEmpty): ?>
|
||||||
|
<label class="pp-item pp-item-none" data-name="不选" data-shown="不选">
|
||||||
|
<input type="radio" name="<?= e($ppName) ?>" value="0" <?= $ppHasSel ? '' : 'checked' ?>>
|
||||||
|
<span class="pp-name">不选</span>
|
||||||
|
</label>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php foreach ($ppPeople as $ppRow): ?>
|
||||||
|
<label class="pp-item"
|
||||||
|
data-name="<?= e((string)$ppRow['name']) ?>"
|
||||||
|
data-shown="<?= e(person_name($ppRow)) ?>">
|
||||||
|
<input type="<?= e($ppInputType) ?>" name="<?= e($ppName) ?>" value="<?= (int)$ppRow['id'] ?>"
|
||||||
|
<?= in_array((int)$ppRow['id'], $ppSelected, true) ? 'checked' : '' ?>>
|
||||||
|
<img class="pp-av" src="<?= e(avatar_url($ppRow['avatar'] ?? null)) ?>" alt="" loading="lazy">
|
||||||
|
<span class="pp-name"><?= e(person_name($ppRow)) ?></span>
|
||||||
|
<span class="pp-initials" hidden></span>
|
||||||
|
</label>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<?php unset($ppRow); ?>
|
||||||
|
<div class="pp-empty" hidden>没有匹配的人。</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<noscript><style>.pp-open { display: none; } .pp-modal { position: static; display: block; background: none; padding: 0; } .pp-modal-box { box-shadow: none; border: 1px solid var(--line); max-height: none; }</style></noscript>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* 日程表单的字段块(新建与编辑共用一份 —— 复制一遍迟早改漏一处)。
|
||||||
|
*
|
||||||
|
* 字段按 **RFC 5545(iCalendar)/ RFC 4791(CalDAV)** 的 VEVENT 属性定:
|
||||||
|
* 标题 = SUMMARY
|
||||||
|
* 地点 = LOCATION
|
||||||
|
* 开始/结束 = DTSTART / DTEND(DATETIME,精确到秒)
|
||||||
|
* 全天 —— VEVENT 没有"全天"这个属性,约定俗成是把 DTSTART 写成 DATE 值;
|
||||||
|
* 我们统一存 DATETIME,全天时时间部分为 00:00:00,导出 ics 时再落成 DATE 值。
|
||||||
|
* 提醒 = VALARM 的 TRIGGER(提前多久);不提醒 = 不写 VALARM
|
||||||
|
* 状态 = STATUS(CONFIRMED / TENTATIVE / CANCELLED)
|
||||||
|
* 忙闲 = TRANSP(OPAQUE=忙 / TRANSPARENT=空闲)
|
||||||
|
* 备注 = DESCRIPTION
|
||||||
|
* 附件 = ATTACH
|
||||||
|
*
|
||||||
|
* 用法(include 前设好):
|
||||||
|
* $evFields 正在编辑的日程(新建时传空数组)
|
||||||
|
* $evAllDay 全天的默认值(新建默认**不勾**,用户 2026-10-02 第 6 条)
|
||||||
|
*/
|
||||||
|
$evF = is_array($evFields ?? null) ? $evFields : [];
|
||||||
|
$evAllDayDefault = (bool)($evAllDay ?? false);
|
||||||
|
$evIsAllDay = array_key_exists('all_day', $evF) ? !empty($evF['all_day']) : $evAllDayDefault;
|
||||||
|
?>
|
||||||
|
<div class="field full">
|
||||||
|
<label for="ev-title">标题 *</label>
|
||||||
|
<input class="input" id="ev-title" name="title" required
|
||||||
|
value="<?= e((string)($evF['title'] ?? '')) ?>" placeholder="如:家长会">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="ev-location">地点</label>
|
||||||
|
<input class="input" id="ev-location" name="location"
|
||||||
|
value="<?= e((string)($evF['location'] ?? '')) ?>" placeholder="如:学校三楼会议室">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label>提醒</label>
|
||||||
|
<?php /* 不提醒 = NULL(导出 ics 时不写 VALARM);档位就是用户第 6 条列的那些。 */ ?>
|
||||||
|
<select class="select" name="reminder_minutes" aria-label="提前多久提醒">
|
||||||
|
<?php $cur = $evF['reminder_minutes'] ?? null; ?>
|
||||||
|
<option value=""<?= $cur === null || $cur === '' ? ' selected' : '' ?>>不提醒</option>
|
||||||
|
<?php foreach (\App\Calendar::REMINDERS as $mins => $word): ?>
|
||||||
|
<option value="<?= (int)$mins ?>"<?= (string)$cur === (string)$mins ? ' selected' : '' ?>>
|
||||||
|
<?= e($word) ?>
|
||||||
|
</option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php /* v24 第 5、6 条:起止各一个「日期 + 时分秒」合在一起的控件(全局统一),
|
||||||
|
不再拆成"日期一格 + 时刻一格"。两个控件长得一样,扫一眼就知道哪个是开始。 */ ?>
|
||||||
|
<div class="field ev-dt" data-dt>
|
||||||
|
<label>开始 *</label>
|
||||||
|
<?php $dtName = 'starts_at'; $dtId = 'ev-start';
|
||||||
|
$dtValue = $evF['starts_at'] ?? null; $dtRequired = true; $dtSeconds = true;
|
||||||
|
require __DIR__ . '/../_datetime.php'; ?>
|
||||||
|
</div>
|
||||||
|
<div class="field ev-dt" data-dt>
|
||||||
|
<label>结束</label>
|
||||||
|
<?php $dtName = 'ends_at'; $dtId = 'ev-end';
|
||||||
|
$dtValue = $evF['ends_at'] ?? null; $dtRequired = false; $dtSeconds = true;
|
||||||
|
require __DIR__ . '/../_datetime.php'; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" id="ev-allday" name="all_day" value="1"
|
||||||
|
<?= $evIsAllDay ? 'checked' : '' ?>>
|
||||||
|
全天
|
||||||
|
</label>
|
||||||
|
<span class="field-hint">勾上就不管具体几点,只占这一天</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="ev-status">状态</label>
|
||||||
|
<select class="select" id="ev-status" name="status">
|
||||||
|
<?php $st = (string)($evF['status'] ?? 'confirmed'); ?>
|
||||||
|
<option value="confirmed"<?= $st === 'confirmed' ? ' selected' : '' ?>>确定</option>
|
||||||
|
<option value="tentative"<?= $st === 'tentative' ? ' selected' : '' ?>>待定</option>
|
||||||
|
<option value="cancelled"<?= $st === 'cancelled' ? ' selected' : '' ?>>取消</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="ev-transp">这段时间算</label>
|
||||||
|
<select class="select" id="ev-transp" name="transp">
|
||||||
|
<?php $tp = (int)($evF['transp'] ?? 0); ?>
|
||||||
|
<option value="0"<?= $tp === 0 ? ' selected' : '' ?>>忙碌</option>
|
||||||
|
<option value="1"<?= $tp === 1 ? ' selected' : '' ?>>空闲(别人还能约)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field full">
|
||||||
|
<label for="ev-note">备注</label>
|
||||||
|
<textarea class="input" id="ev-note" name="note" rows="3"
|
||||||
|
placeholder="要带什么、跟谁一起…"><?= e((string)($evF['note'] ?? '')) ?></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field full">
|
||||||
|
<label for="ev-attach">附件(可多选)</label>
|
||||||
|
<input class="input" type="file" id="ev-attach" name="attach[]" multiple>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
/* 「全天」勾上就把两个时刻控件的**时间部分**收起来 —— 日期还得留着。
|
||||||
|
只藏 .dt-time(时/分/秒那一段),.dt-date 是日期,藏了就没法选哪天了。 */
|
||||||
|
var box = document.getElementById('ev-allday');
|
||||||
|
if (!box) return;
|
||||||
|
function sync() {
|
||||||
|
Array.prototype.forEach.call(document.querySelectorAll('[data-dt] .dt-time'), function (el) {
|
||||||
|
el.hidden = box.checked;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
box.addEventListener('change', sync);
|
||||||
|
sync();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<?php unset($evF, $evAllDayDefault, $evIsAllDay, $evFields, $evAllDay); ?>
|
||||||
@@ -102,12 +102,17 @@ $canWrite = \App\Auth::canWrite();
|
|||||||
<form class="inline-form" method="post" action="<?= e(url('calendar/cal_share')) ?>">
|
<form class="inline-form" method="post" action="<?= e(url('calendar/cal_share')) ?>">
|
||||||
<?= csrf_field() ?>
|
<?= csrf_field() ?>
|
||||||
<input type="hidden" name="id" value="<?= (int)$c['id'] ?>">
|
<input type="hidden" name="id" value="<?= (int)$c['id'] ?>">
|
||||||
<select class="select" name="person_id">
|
<?php
|
||||||
<?php foreach ($people as $p): ?>
|
$ppName = 'person_id';
|
||||||
<?php if ((int)$p['id'] === (int)\App\Auth::personId()) { continue; } ?>
|
$ppPeople = array_values(array_filter($people,
|
||||||
<option value="<?= (int)$p['id'] ?>"><?= e(person_name($p)) ?></option>
|
fn($p) => (int)$p['id'] !== (int)\App\Auth::personId()));
|
||||||
<?php endforeach; ?>
|
$ppSelected = [];
|
||||||
</select>
|
$ppMode = 'single';
|
||||||
|
$ppOpenBtn = '选择要共享给谁';
|
||||||
|
$ppTitle = '共享给谁';
|
||||||
|
$ppEmptyText = '未选择';
|
||||||
|
require __DIR__ . '/../_person_picker.php';
|
||||||
|
?>
|
||||||
<select class="select" name="perm">
|
<select class="select" name="perm">
|
||||||
<option value="read">只读</option>
|
<option value="read">只读</option>
|
||||||
<option value="write">可写</option>
|
<option value="write">可写</option>
|
||||||
@@ -129,6 +134,9 @@ $canWrite = \App\Auth::canWrite();
|
|||||||
<?php if ($canWrite): ?>
|
<?php if ($canWrite): ?>
|
||||||
<td>
|
<td>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
|
<?php /* v24 第 7 条:每本都能导出 .ics(预置本也能 —— 导的是派生出来的内容)。 */ ?>
|
||||||
|
<a class="btn btn-ghost btn-sm"
|
||||||
|
href="<?= e(url('calendar/export', ['id' => (int)$c['id']])) ?>">导出 ics</a>
|
||||||
<?php if (($c['kind'] ?? '') === 'custom'): ?>
|
<?php if (($c['kind'] ?? '') === 'custom'): ?>
|
||||||
<a class="btn btn-ghost btn-sm"
|
<a class="btn btn-ghost btn-sm"
|
||||||
href="<?= e(url('calendar/calendars', ['edit' => (int)$c['id']])) ?>">编辑</a>
|
href="<?= e(url('calendar/calendars', ['edit' => (int)$c['id']])) ?>">编辑</a>
|
||||||
@@ -152,6 +160,89 @@ $canWrite = \App\Auth::canWrite();
|
|||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<?php if ($canWrite): ?>
|
||||||
|
<?php /* v24 第 7 条:把一个 .ics 文件里的日程整批导进某一本。
|
||||||
|
只能导进自定义本 —— 观影/旅行两本是派生的,塞进去就成了孤儿日程
|
||||||
|
(日历里有、观影列表里没有)。 */ ?>
|
||||||
|
<div class="card card-pad">
|
||||||
|
<h2 class="cinema-h2">
|
||||||
|
导入 ics
|
||||||
|
<span class="hint">把别的日历导出的 .ics 文件整批装进来</span>
|
||||||
|
</h2>
|
||||||
|
<p class="small muted" style="margin:0 0 16px">
|
||||||
|
同一个文件导两次也<b>不会重复</b>:每条日程有个全球唯一的 UID,
|
||||||
|
已经导过的会被更新,而不是再插一条。
|
||||||
|
</p>
|
||||||
|
<form method="post" action="<?= e(url('calendar/import')) ?>" enctype="multipart/form-data">
|
||||||
|
<?= csrf_field() ?>
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="field">
|
||||||
|
<label for="im-cal">导进哪一本 *</label>
|
||||||
|
<select class="select" id="im-cal" name="calendar_id">
|
||||||
|
<?php foreach ($cals as $c): ?>
|
||||||
|
<?php if (($c['kind'] ?? '') !== 'custom') { continue; } ?>
|
||||||
|
<option value="<?= (int)$c['id'] ?>"><?= e((string)$c['name']) ?></option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="im-file">.ics 文件 *</label>
|
||||||
|
<input class="input" type="file" id="im-file" name="ics" accept=".ics,text/calendar" required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="btn-row">
|
||||||
|
<button class="btn btn-sm" type="submit">导入</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php /* v24 第 7 条后半:CalDAV —— 把我的日历本订阅到手机/别的日历程序里 */ ?>
|
||||||
|
<div class="card card-pad">
|
||||||
|
<h2 class="cinema-h2">
|
||||||
|
CalDAV 订阅
|
||||||
|
<span class="hint">在手机日历 / DAVx⁵ / Thunderbird 里订阅你的日历本</span>
|
||||||
|
</h2>
|
||||||
|
<p class="small muted" style="margin:0 0 12px">
|
||||||
|
填下面这个地址 + 你的<b>姓名或手机号</b> + <b>CalDAV 口令</b>,就能把「我的日历本」
|
||||||
|
同步到别的日历程序里。这是<b>只读</b>订阅的 —— 在手机上改日程请回到本站来改。
|
||||||
|
<br>口令与登录密码是<b>分开的</b>:订阅凭据会被客户端长期存着,所以另发一个,
|
||||||
|
随时可换、不影响登录。
|
||||||
|
</p>
|
||||||
|
<div class="field">
|
||||||
|
<label>服务器地址</label>
|
||||||
|
<input class="input mono" readonly
|
||||||
|
value="<?= e(rtrim((string)fb_config('base_url', ''), '/') . '/dav/') ?>">
|
||||||
|
<span class="tip">在日历客户端里填这一条即可,会列出你所有的日历本。</span>
|
||||||
|
</div>
|
||||||
|
<?php if (!empty($cdPlain)): ?>
|
||||||
|
<p class="tip" style="background:#FFF6E5;border-color:#E8C98A;padding:8px 12px;border-radius:8px">
|
||||||
|
<b>这是你的新口令,只显示这一次,请立刻复制走:</b>
|
||||||
|
<br><code class="mono" style="font-size:15px"><?= e($cdPlain) ?></code>
|
||||||
|
</p>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if ($canWrite): ?>
|
||||||
|
<div class="btn-row">
|
||||||
|
<form method="post" action="<?= e(url('calendar/caldav_token')) ?>" class="res-inline">
|
||||||
|
<?= csrf_field() ?>
|
||||||
|
<button class="btn btn-sm" type="submit">
|
||||||
|
<?= !empty($cdHas) ? '重置 CalDAV 口令' : '生成 CalDAV 口令' ?>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<?php if (!empty($cdHas)): ?>
|
||||||
|
<form method="post" action="<?= e(url('calendar/caldav_revoke')) ?>" class="res-inline">
|
||||||
|
<?= csrf_field() ?>
|
||||||
|
<button class="btn btn-ghost btn-sm" type="submit"
|
||||||
|
onclick="return confirm('撤销后所有已订阅的客户端都会失去访问。')">撤销口令</button>
|
||||||
|
</form>
|
||||||
|
<span class="node-badge">口令已配置</span>
|
||||||
|
<?php else: ?>
|
||||||
|
<span class="node-badge">还没配置口令</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
<?php if ($shared): ?>
|
<?php if ($shared): ?>
|
||||||
<div class="card card-pad">
|
<div class="card card-pad">
|
||||||
<h2 class="cinema-h2">别人共享给我的</h2>
|
<h2 class="cinema-h2">别人共享给我的</h2>
|
||||||
|
|||||||
@@ -16,61 +16,75 @@ $canWrite = \App\Auth::canWrite() && !empty($ev['_can_write']);
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card card-pad">
|
<div class="card card-pad">
|
||||||
|
<?php $ym = substr((string)$ev['starts_at'], 0, 7); ?>
|
||||||
<?php if (!$canWrite): ?>
|
<?php if (!$canWrite): ?>
|
||||||
<dl class="kv">
|
<dl class="kv">
|
||||||
<div class="kv-row"><dt>日期</dt><dd><?= e((string)$ev['starts_at']) ?></dd></div>
|
<div class="kv-row"><dt>开始</dt>
|
||||||
<?php if (!empty($ev['ends_at'])): ?>
|
<dd><?= e(empty($ev['all_day']) ? (string)$ev['starts_at'] : substr((string)$ev['starts_at'], 0, 10)) ?></dd>
|
||||||
<div class="kv-row"><dt>到</dt><dd><?= e((string)$ev['ends_at']) ?></dd></div>
|
|
||||||
<?php endif; ?>
|
|
||||||
<div class="kv-row"><dt>时间</dt>
|
|
||||||
<dd><?= empty($ev['all_day']) ? e(substr((string)$ev['start_time'], 0, 5) . ' – ' . substr((string)$ev['end_time'], 0, 5)) : '全天' ?></dd>
|
|
||||||
</div>
|
</div>
|
||||||
|
<?php if (!empty($ev['ends_at'])): ?>
|
||||||
|
<div class="kv-row"><dt>结束</dt>
|
||||||
|
<dd><?= e(empty($ev['all_day']) ? (string)$ev['ends_at'] : substr((string)$ev['ends_at'], 0, 10)) ?></dd>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if (!empty($ev['all_day'])): ?>
|
||||||
|
<div class="kv-row"><dt></dt><dd>全天</dd></div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if (!empty($ev['location'])): ?>
|
||||||
|
<div class="kv-row"><dt>地点</dt><dd><?= e((string)$ev['location']) ?></dd></div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if (($ev['reminder_minutes'] ?? null) !== null): ?>
|
||||||
|
<div class="kv-row"><dt>提醒</dt>
|
||||||
|
<dd><?= e(\App\Calendar::REMINDERS[(int)$ev['reminder_minutes']] ?? '') ?></dd>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
<?php if (!empty($ev['note'])): ?>
|
<?php if (!empty($ev['note'])): ?>
|
||||||
<div class="kv-row"><dt>备注</dt><dd><?= nl2br(e((string)$ev['note'])) ?></dd></div>
|
<div class="kv-row"><dt>备注</dt><dd><?= nl2br(e((string)$ev['note'])) ?></dd></div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
<?php $roAs = \App\Attachment::list(\App\Calendar::ATTACH, (int)$ev['id']); ?>
|
||||||
|
<?php if ($roAs): ?>
|
||||||
|
<div class="kv-row"><dt>附件</dt>
|
||||||
|
<dd class="attach-row">
|
||||||
|
<?php foreach ($roAs as $a): ?>
|
||||||
|
<span class="attach-item">
|
||||||
|
<a href="<?= e(media_url((string)$a['key'])) ?>" target="_blank" rel="noopener">
|
||||||
|
<?= (int)$a['is_image'] === 1 ? '🖼' : '📄' ?> <?= e((string)($a['name'] ?: '附件')) ?>
|
||||||
|
</a>
|
||||||
|
<a class="link-quiet" href="<?= e(media_url((string)$a['key'], ['dl' => 1])) ?>">下载</a>
|
||||||
|
</span>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
</dl>
|
</dl>
|
||||||
<p class="empty-hint">这本日历你只有只读权限,看得到但不能改。</p>
|
<p class="empty-hint">这本日历你只有只读权限,看得到但不能改。</p>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<form method="post" action="<?= e(url('calendar/event_update')) ?>">
|
<form method="post" action="<?= e(url('calendar/event_update')) ?>" enctype="multipart/form-data">
|
||||||
<?= csrf_field() ?>
|
<?= csrf_field() ?>
|
||||||
<input type="hidden" name="id" value="<?= (int)$ev['id'] ?>">
|
<input type="hidden" name="id" value="<?= (int)$ev['id'] ?>">
|
||||||
<?php $ym = substr((string)$ev['starts_at'], 0, 7); ?>
|
|
||||||
<input type="hidden" name="ym" value="<?= e($ym) ?>">
|
<input type="hidden" name="ym" value="<?= e($ym) ?>">
|
||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
<div class="field full">
|
<?php $evFields = $ev; $evAllDay = false; require __DIR__ . '/_event_fields.php'; ?>
|
||||||
<label for="ev-title">标题 *</label>
|
|
||||||
<input class="input" id="ev-title" name="title" required value="<?= e((string)$ev['title']) ?>">
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label for="ev-date">开始日期 *</label>
|
|
||||||
<input class="input" type="date" id="ev-date" name="date" required
|
|
||||||
value="<?= e((string)$ev['starts_at']) ?>">
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label for="ev-end">结束日期(跨天时填)</label>
|
|
||||||
<input class="input" type="date" id="ev-end" name="end_date" value="<?= e((string)($ev['ends_at'] ?? '')) ?>">
|
|
||||||
</div>
|
|
||||||
<div class="field full">
|
|
||||||
<label><input type="checkbox" name="all_day" value="1" <?= empty($ev['all_day']) ? '' : 'checked' ?>> 全天(不填具体时分)</label>
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label for="ev-st">开始时刻</label>
|
|
||||||
<input class="input" type="time" id="ev-st" name="start_time" value="<?= e(substr((string)($ev['start_time'] ?? ''), 0, 5)) ?>">
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label for="ev-et">结束时刻</label>
|
|
||||||
<input class="input" type="time" id="ev-et" name="end_time" value="<?= e(substr((string)($ev['end_time'] ?? ''), 0, 5)) ?>">
|
|
||||||
</div>
|
|
||||||
<div class="field full">
|
|
||||||
<label for="ev-note">备注</label>
|
|
||||||
<textarea class="input" id="ev-note" name="note" rows="3"><?= e((string)($ev['note'] ?? '')) ?></textarea>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="btn-row">
|
<div class="btn-row">
|
||||||
<button class="btn" type="submit">保存</button>
|
<button class="btn" type="submit">保存</button>
|
||||||
<a class="btn btn-ghost" href="<?= e(url('calendar', ['ym' => $ym])) ?>">取消</a>
|
<a class="btn btn-ghost" href="<?= e(url('calendar', ['ym' => $ym])) ?>">取消</a>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
<?php $as = \App\Attachment::list(\App\Calendar::ATTACH, (int)$ev['id']); ?>
|
||||||
|
<?php if ($as): ?>
|
||||||
|
<div class="ev-attach-list">
|
||||||
|
<span class="res-label">已有附件</span>
|
||||||
|
<?php
|
||||||
|
$attRows = $as;
|
||||||
|
$attCanEdit = true;
|
||||||
|
$attDelUrl = 'calendar/attach_del';
|
||||||
|
$attDelField = 'attach_id';
|
||||||
|
$attExtra = ['id' => (int)$ev['id']];
|
||||||
|
require __DIR__ . '/../_attachments.php';
|
||||||
|
?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
<?php /* ⚠️ 删除表单必须放在上面那个表单**外面**:<form> 套 <form> 是非法 HTML,
|
<?php /* ⚠️ 删除表单必须放在上面那个表单**外面**:<form> 套 <form> 是非法 HTML,
|
||||||
浏览器会把外层表单在嵌套处切开,"保存"按钮就失效了。 */ ?>
|
浏览器会把外层表单在嵌套处切开,"保存"按钮就失效了。 */ ?>
|
||||||
<form method="post" action="<?= e(url('calendar/event_del')) ?>" class="ev-del"
|
<form method="post" action="<?= e(url('calendar/event_del')) ?>" class="ev-del"
|
||||||
|
|||||||
+105
-46
@@ -17,19 +17,55 @@
|
|||||||
* @param array $people 名册(共享给别人时用)
|
* @param array $people 名册(共享给别人时用)
|
||||||
*/
|
*/
|
||||||
$canWrite = \App\Auth::canWrite();
|
$canWrite = \App\Auth::canWrite();
|
||||||
|
$view = (string)($view ?? 'month');
|
||||||
|
$anchor = (string)($anchor ?? date('Y-m-d'));
|
||||||
$ts = strtotime($ym . '-01') ?: time();
|
$ts = strtotime($ym . '-01') ?: time();
|
||||||
$prevYm = date('Y-m', strtotime('-1 month', $ts));
|
$prevYm = date('Y-m', strtotime('-1 month', $ts));
|
||||||
$nextYm = date('Y-m', strtotime('+1 month', $ts));
|
$nextYm = date('Y-m', strtotime('+1 month', $ts));
|
||||||
$thisYm = date('Y-m');
|
$thisYm = date('Y-m');
|
||||||
$writable = array_values(array_filter($cals, static fn($c) => \App\Calendar::canWrite($c, (int)\App\Auth::personId())));
|
$writable = array_values(array_filter($cals, static fn($c) => \App\Calendar::canWrite($c, (int)\App\Auth::personId())));
|
||||||
|
|
||||||
|
// 三种看法各自的「上一段 / 下一段」:月按整月走,周按 7 天走,天按 1 天走
|
||||||
|
$step = $view === 'week' ? 7 : ($view === 'day' ? 1 : 0);
|
||||||
|
$prevD = date('Y-m-d', strtotime('-' . $step . ' days', strtotime($anchor)));
|
||||||
|
$nextD = date('Y-m-d', strtotime('+' . $step . ' days', strtotime($anchor)));
|
||||||
|
$todayD = date('Y-m-d');
|
||||||
|
$headText = $view === 'month' ? date('Y 年 n 月', $ts)
|
||||||
|
: ($view === 'week' ? date('Y 年 n 月 j 日', strtotime($anchor)) . ' 起这一周'
|
||||||
|
: date('Y 年 n 月 j 日', strtotime($anchor)));
|
||||||
|
$mkUrl = static function (string $v) use ($ym, $prevD, $nextD, $todayD, $prevYm, $nextYm, $thisYm) {
|
||||||
|
if ($v === 'month') {
|
||||||
|
return url('calendar', ['view' => 'month', 'ym' => $ym]);
|
||||||
|
}
|
||||||
|
return null; // 周/天要把具体哪天传进去,由调用方拼
|
||||||
|
};
|
||||||
?>
|
?>
|
||||||
<div class="page-head">
|
<div class="page-head">
|
||||||
<h1>日程</h1>
|
<h1>日程</h1>
|
||||||
<span class="sub"><?= e(date('Y 年 n 月', $ts)) ?></span>
|
<span class="sub"><?= e($headText) ?></span>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<a class="btn btn-ghost" href="<?= e(url('calendar', ['ym' => $prevYm])) ?>">上一月</a>
|
<?php /* v24 第 4 条:月 / 周 / 天三选一 */ ?>
|
||||||
<a class="btn btn-ghost" href="<?= e(url('calendar', ['ym' => $thisYm])) ?>">今天</a>
|
<span class="seg" role="group" aria-label="看法">
|
||||||
<a class="btn btn-ghost" href="<?= e(url('calendar', ['ym' => $nextYm])) ?>">下一月</a>
|
<a class="seg-btn<?= $view === 'month' ? ' is-on' : '' ?>"
|
||||||
|
href="<?= e(url('calendar', ['view' => 'month', 'ym' => $ym])) ?>">月</a>
|
||||||
|
<a class="seg-btn<?= $view === 'week' ? ' is-on' : '' ?>"
|
||||||
|
href="<?= e(url('calendar', ['view' => 'week', 'd' => $anchor])) ?>">周</a>
|
||||||
|
<a class="seg-btn<?= $view === 'day' ? ' is-on' : '' ?>"
|
||||||
|
href="<?= e(url('calendar', ['view' => 'day', 'd' => $anchor])) ?>">天</a>
|
||||||
|
</span>
|
||||||
|
<?php if ($view === 'month'): ?>
|
||||||
|
<a class="btn btn-ghost" href="<?= e(url('calendar', ['view' => 'month', 'ym' => $prevYm])) ?>">上一月</a>
|
||||||
|
<a class="btn btn-ghost" href="<?= e(url('calendar', ['view' => 'month', 'ym' => $thisYm])) ?>">今天</a>
|
||||||
|
<a class="btn btn-ghost" href="<?= e(url('calendar', ['view' => 'month', 'ym' => $nextYm])) ?>">下一月</a>
|
||||||
|
<?php else: ?>
|
||||||
|
<a class="btn btn-ghost" href="<?= e(url('calendar', ['view' => $view, 'd' => $prevD])) ?>">
|
||||||
|
<?= $view === 'week' ? '上一周' : '前一天' ?>
|
||||||
|
</a>
|
||||||
|
<a class="btn btn-ghost" href="<?= e(url('calendar', ['view' => $view, 'd' => $todayD])) ?>">今天</a>
|
||||||
|
<a class="btn btn-ghost" href="<?= e(url('calendar', ['view' => $view, 'd' => $nextD])) ?>">
|
||||||
|
<?= $view === 'week' ? '下一周' : '后一天' ?>
|
||||||
|
</a>
|
||||||
|
<?php endif; ?>
|
||||||
<a class="btn btn-ghost" href="<?= e(url('calendar/calendars')) ?>">日历本</a>
|
<a class="btn btn-ghost" href="<?= e(url('calendar/calendars')) ?>">日历本</a>
|
||||||
<?php if ($canWrite && $writable): ?>
|
<?php if ($canWrite && $writable): ?>
|
||||||
<button class="btn" type="button" id="calAddBtn">新建日程</button>
|
<button class="btn" type="button" id="calAddBtn">新建日程</button>
|
||||||
@@ -68,7 +104,7 @@ $writable = array_values(array_filter($cals, static fn($c) => \App\Calendar::can
|
|||||||
<?php if ($canWrite && $writable): ?>
|
<?php if ($canWrite && $writable): ?>
|
||||||
<div class="card card-pad cal-add" id="calAddCard" hidden>
|
<div class="card card-pad cal-add" id="calAddCard" hidden>
|
||||||
<h2 class="cinema-h2">新建日程</h2>
|
<h2 class="cinema-h2">新建日程</h2>
|
||||||
<form method="post" action="<?= e(url('calendar/event_add')) ?>">
|
<form method="post" action="<?= e(url('calendar/event_add')) ?>" enctype="multipart/form-data">
|
||||||
<?= csrf_field() ?>
|
<?= csrf_field() ?>
|
||||||
<input type="hidden" name="ym" value="<?= e($ym) ?>">
|
<input type="hidden" name="ym" value="<?= e($ym) ?>">
|
||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
@@ -80,34 +116,14 @@ $writable = array_values(array_filter($cals, static fn($c) => \App\Calendar::can
|
|||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<?php /* v24 第 6 条:字段按 iCalendar / CalDAV 的 VEVENT 定(见 _event_fields.php)。
|
||||||
<label for="ce-title">标题 *</label>
|
新建时「全天」默认**不勾**(用户原话),开始时间默认今天 09:00。 */ ?>
|
||||||
<input class="input" id="ce-title" name="title" required placeholder="如:家长会">
|
<?php
|
||||||
</div>
|
$evFields = ['starts_at' => ($ym === $thisYm ? date('Y-m-d') : $ym . '-01') . ' 09:00:00',
|
||||||
<div class="field">
|
'ends_at' => ($ym === $thisYm ? date('Y-m-d') : $ym . '-01') . ' 10:00:00'];
|
||||||
<label for="ce-date">日期 *</label>
|
$evAllDay = false;
|
||||||
<input class="input" type="date" id="ce-date" name="date" required
|
require __DIR__ . '/_event_fields.php';
|
||||||
value="<?= e($ym === $thisYm ? date('Y-m-d') : $ym . '-01') ?>">
|
?>
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label for="ce-end">结束日期(跨天时填)</label>
|
|
||||||
<input class="input" type="date" id="ce-end" name="end_date">
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label><input type="checkbox" id="ce-allday" name="all_day" value="1" checked> 全天(不填具体时分)</label>
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label for="ce-note">备注</label>
|
|
||||||
<input class="input" id="ce-note" name="note">
|
|
||||||
</div>
|
|
||||||
<div class="field" id="ce-time-s">
|
|
||||||
<label for="ce-st">开始时刻</label>
|
|
||||||
<input class="input" type="time" id="ce-st" name="start_time">
|
|
||||||
</div>
|
|
||||||
<div class="field" id="ce-time-e">
|
|
||||||
<label for="ce-et">结束时刻</label>
|
|
||||||
<input class="input" type="time" id="ce-et" name="end_time">
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="btn-row">
|
<div class="btn-row">
|
||||||
<button class="btn btn-sm" type="submit">加上</button>
|
<button class="btn btn-sm" type="submit">加上</button>
|
||||||
@@ -117,6 +133,60 @@ $writable = array_values(array_filter($cals, static fn($c) => \App\Calendar::can
|
|||||||
</div>
|
</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if ($view === 'week' || $view === 'day'): ?>
|
||||||
|
<?php /* ===== 周 / 天:按小时铺的时间网格(全天日程单独挂顶上) ===== */ ?>
|
||||||
|
<div class="card cal-tg-card">
|
||||||
|
<div class="tg-head">
|
||||||
|
<div class="tg-gutter"></div>
|
||||||
|
<?php foreach (($cols ?? []) as $c): ?>
|
||||||
|
<div class="tg-dayname<?= $c['is_today'] ? ' is-today' : '' ?>">
|
||||||
|
<span class="tg-wd"><?= e(['日','一','二','三','四','五','六'][$c['w']]) ?></span>
|
||||||
|
<span class="tg-dd"><?= (int)$c['day'] ?></span>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php $tgAll = $tgrid['all_day'] ?? []; ?>
|
||||||
|
<?php if (array_filter($tgAll)): ?>
|
||||||
|
<div class="tg-allday">
|
||||||
|
<div class="tg-gutter"><span class="tg-tag">全天</span></div>
|
||||||
|
<?php foreach (($cols ?? []) as $i => $c): ?>
|
||||||
|
<div class="tg-adcell">
|
||||||
|
<?php foreach (($tgAll[$i] ?? []) as $ev): ?>
|
||||||
|
<a class="cal-ev is-allday" data-cal="<?= (int)$ev['cal'] ?>"
|
||||||
|
style="background: <?= e((string)$ev['color']) ?>;"
|
||||||
|
href="<?= e((string)$ev['href']) ?>"
|
||||||
|
title="<?= e((string)$ev['title']) ?>"><?= e((string)$ev['title']) ?></a>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<div class="tg-body">
|
||||||
|
<div class="tg-gutter tg-hours">
|
||||||
|
<?php for ($h = 0; $h < 24; $h++): ?>
|
||||||
|
<span class="tg-hr"><?= sprintf('%02d', $h) ?></span>
|
||||||
|
<?php endfor; ?>
|
||||||
|
</div>
|
||||||
|
<?php foreach (($cols ?? []) as $i => $c): ?>
|
||||||
|
<div class="tg-col<?= $c['is_today'] ? ' is-today' : '' ?>">
|
||||||
|
<?php for ($h = 0; $h < 24; $h++): ?>
|
||||||
|
<span class="tg-line"></span>
|
||||||
|
<?php endfor; ?>
|
||||||
|
<?php foreach (($tgrid['timed'][$i] ?? []) as $ev): ?>
|
||||||
|
<a class="tg-block" data-cal="<?= (int)$ev['cal'] ?>"
|
||||||
|
style="background: <?= e((string)$ev['color']) ?>; top: <?= (float)$ev['top'] ?>%; height: <?= (float)$ev['height'] ?>%;"
|
||||||
|
href="<?= e((string)$ev['href']) ?>"
|
||||||
|
title="<?= e((string)$ev['title']) ?>">
|
||||||
|
<b><?= e((string)$ev['time']) ?></b> <?= e((string)$ev['title']) ?>
|
||||||
|
</a>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php else: ?>
|
||||||
<div class="card cal-grid-card">
|
<div class="card cal-grid-card">
|
||||||
<div class="cal-head">
|
<div class="cal-head">
|
||||||
<?php foreach (['日', '一', '二', '三', '四', '五', '六'] as $wd): ?>
|
<?php foreach (['日', '一', '二', '三', '四', '五', '六'] as $wd): ?>
|
||||||
@@ -150,6 +220,7 @@ $writable = array_values(array_filter($cals, static fn($c) => \App\Calendar::can
|
|||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -180,18 +251,6 @@ $writable = array_values(array_filter($cals, static fn($c) => \App\Calendar::can
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (cancel && card) { cancel.addEventListener('click', function () { card.hidden = true; }); }
|
if (cancel && card) { cancel.addEventListener('click', function () { card.hidden = true; }); }
|
||||||
|
/* 「全天」勾上藏掉时刻那一段的逻辑在 _event_fields.php 里(两个页面共用一份) */
|
||||||
/* 「全天」勾上就把两个时刻输入框藏掉 —— 没有具体时分就是全天(用户第 7 条) */
|
|
||||||
var allday = document.getElementById('ce-allday');
|
|
||||||
var ts = document.getElementById('ce-time-s');
|
|
||||||
var te = document.getElementById('ce-time-e');
|
|
||||||
if (allday && ts && te) {
|
|
||||||
function syncTime() {
|
|
||||||
var hide = allday.checked;
|
|
||||||
ts.hidden = hide; te.hidden = hide;
|
|
||||||
}
|
|
||||||
allday.addEventListener('change', syncTime);
|
|
||||||
syncTime();
|
|
||||||
}
|
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* 新建 / 编辑纪念日(v24 用户第 13 条)
|
||||||
|
*
|
||||||
|
* 用户原话:「可以根据阴历和阳历来设置纪念日 …… 纪念日,需要按照阴历和阳历进行重复。」
|
||||||
|
* 所以重复方式这一栏是核心:阳历 / 农历 / 只此一次。
|
||||||
|
* 选农历时,下面的月/日填的是**农历**的月日(不是什么公历日期换算)。
|
||||||
|
*
|
||||||
|
* @param array|null $editing 编辑时的既有记录
|
||||||
|
* @param array $people 名册
|
||||||
|
*/
|
||||||
|
$e0 = $editing ?: [];
|
||||||
|
$v = static fn(string $k, $def = '') => $e0[$k] ?? $def;
|
||||||
|
$repeat = (string)($e0['repeat_type'] ?? 'solar');
|
||||||
|
?>
|
||||||
|
<div class="page-head">
|
||||||
|
<h1><?= $editing ? '编辑纪念日' : '新建纪念日' ?></h1>
|
||||||
|
<div class="actions">
|
||||||
|
<a class="btn btn-ghost" href="<?= e(url('days')) ?>">返回</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form class="card card-pad" method="post" action="<?= e(url('days/save')) ?>">
|
||||||
|
<?= csrf_field() ?>
|
||||||
|
<?php if ($editing): ?><input type="hidden" name="id" value="<?= (int)$e0['id'] ?>"><?php endif; ?>
|
||||||
|
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="field full">
|
||||||
|
<label for="an-title">叫什么 *</label>
|
||||||
|
<input class="input" id="an-title" name="title" required
|
||||||
|
value="<?= e((string)$v('title')) ?>" placeholder="如:结婚纪念日">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="an-repeat">按什么重复 *</label>
|
||||||
|
<select class="select" id="an-repeat" name="repeat_type">
|
||||||
|
<?php foreach (\App\Anniversary::REPEAT as $rk => $rv): ?>
|
||||||
|
<option value="<?= e($rk) ?>" <?= $repeat === $rk ? 'selected' : '' ?>><?= e($rv) ?></option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
<span class="tip">选「农历」时,下面填的是**农历**的月日(不是公历)。</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="an-kind">分类</label>
|
||||||
|
<input class="input" id="an-kind" name="kind" list="anKinds"
|
||||||
|
value="<?= e((string)$v('kind')) ?>" placeholder="如:结婚纪念日">
|
||||||
|
<datalist id="anKinds">
|
||||||
|
<?php foreach (\App\Anniversary::PRESET_KINDS as $k): ?>
|
||||||
|
<option value="<?= e($k) ?>"></option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</datalist>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="an-month">月 *</label>
|
||||||
|
<input class="input" id="an-month" name="month" type="number" min="1" max="12" required
|
||||||
|
value="<?= (int)$v('month', 1) ?>">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="an-day">日 *</label>
|
||||||
|
<input class="input" id="an-day" name="day" type="number" min="1" max="31" required
|
||||||
|
value="<?= (int)$v('day', 1) ?>">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="an-person">与谁相关</label>
|
||||||
|
<select class="select" id="an-person" name="person_id">
|
||||||
|
<option value="0">不指定</option>
|
||||||
|
<?php foreach ($people as $p): ?>
|
||||||
|
<option value="<?= (int)$p['id'] ?>" <?= (int)$v('person_id', 0) === (int)$p['id'] ? 'selected' : '' ?>>
|
||||||
|
<?= e(person_name($p)) ?>
|
||||||
|
</option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="an-leap">农历闰月</label>
|
||||||
|
<label class="chk-inline">
|
||||||
|
<input type="checkbox" id="an-leap" name="is_leap" value="1" <?= !empty($e0['is_leap']) ? 'checked' : '' ?>>
|
||||||
|
<span>这个农历月是闰月</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field full">
|
||||||
|
<label for="an-note">备注</label>
|
||||||
|
<textarea class="input" id="an-note" name="note" rows="2"><?= e((string)$v('note')) ?></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="btn-row">
|
||||||
|
<button class="btn" type="submit"><?= $editing ? '保存' : '添加' ?></button>
|
||||||
|
<a class="btn btn-ghost" href="<?= e(url('days')) ?>">取消</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* 纪念日(v24 用户第 13 条)
|
||||||
|
*
|
||||||
|
* 用户原话:「增加纪念日模块,可以根据阴历和阳历来设置纪念日,在纪念日模块中,
|
||||||
|
* 可以默认看到已经保存的名册中的人的各种生日,生日本身就是纪念日的一种。」
|
||||||
|
*
|
||||||
|
* 所以这一页两块:
|
||||||
|
* ① 临近的(今天起 60 天内)—— 一眼看到"该过什么日子了";
|
||||||
|
* ② 全部纪念日 —— 名册里每个人的生日(实时派生)+ 你自己加的,混在一起按日期排。
|
||||||
|
*
|
||||||
|
* @param array $rows 全部纪念日(含派生生日:source='birthday_solar'/'birthday_lunar')
|
||||||
|
* @param array $upcoming 接下来 60 天的
|
||||||
|
* @param array $people 名册(新建表单里选"与谁相关")
|
||||||
|
*/
|
||||||
|
$canWrite = \App\Auth::canWrite();
|
||||||
|
$kindBadge = static function (array $r): string {
|
||||||
|
$k = (string)($r['kind'] ?? '');
|
||||||
|
return $k === '' ? '' : '<span class="node-badge">' . e($k) . '</span>';
|
||||||
|
};
|
||||||
|
$srcWord = static function (array $r): string {
|
||||||
|
$src = (string)($r['source'] ?? 'custom');
|
||||||
|
if ($src === 'birthday_solar') {
|
||||||
|
return '名册生日(阳历)';
|
||||||
|
}
|
||||||
|
if ($src === 'birthday_lunar') {
|
||||||
|
return '名册生日(农历)';
|
||||||
|
}
|
||||||
|
return \App\Anniversary::repeatWord((string)($r['repeat_type'] ?? 'solar'));
|
||||||
|
};
|
||||||
|
?>
|
||||||
|
<div class="page-head">
|
||||||
|
<h1>纪念日</h1>
|
||||||
|
<span class="sub">生日本身就是一种纪念日 · 阳历与农历都能记</span>
|
||||||
|
<div class="actions">
|
||||||
|
<?php if ($canWrite): ?>
|
||||||
|
<a class="btn" href="<?= e(url('days/new')) ?>">新建纪念日</a>
|
||||||
|
<?php endif; ?>
|
||||||
|
<a class="btn btn-ghost" href="<?= e(url('calendar')) ?>">看日历</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card card-pad">
|
||||||
|
<h2 class="cinema-h2">接下来的日子</h2>
|
||||||
|
<?php if (!$upcoming): ?>
|
||||||
|
<p class="empty-hint">未来 60 天里没有纪念日。</p>
|
||||||
|
<?php else: ?>
|
||||||
|
<ul class="ann-up">
|
||||||
|
<?php foreach ($upcoming as $u): ?>
|
||||||
|
<li class="ann-up-item">
|
||||||
|
<span class="ann-days"><?= (int)$u['days'] === 0 ? '今天' : ((int)$u['days'] . ' 天后') ?></span>
|
||||||
|
<span class="ann-date"><?= e(substr((string)$u['date'], 5)) ?></span>
|
||||||
|
<span class="ann-title"><?= e((string)$u['title']) ?></span>
|
||||||
|
<?php if ($u['age'] !== null): ?><span class="small muted"><?= (int)$u['age'] ?> 岁</span><?php endif; ?>
|
||||||
|
<?= $kindBadge($u) ?>
|
||||||
|
</li>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</ul>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card card-pad">
|
||||||
|
<h2 class="cinema-h2">全部纪念日(<?= count($rows) ?>)</h2>
|
||||||
|
<?php if (!$rows): ?>
|
||||||
|
<p class="empty-hint">还没有纪念日。先到名册里把家人的生日填上,这里就会自动出现。</p>
|
||||||
|
<?php else: ?>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>日期</th><th>纪念日</th><th>分类</th><th>重复</th><th></th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php foreach ($rows as $r): ?>
|
||||||
|
<tr>
|
||||||
|
<td class="nowrap"><?= (int)$r['month'] ?> 月 <?= (int)$r['day'] ?> 日</td>
|
||||||
|
<td>
|
||||||
|
<?= e((string)$r['title']) ?>
|
||||||
|
<?php if (!empty($r['is_leap'])): ?><span class="small muted">(闰月)</span><?php endif; ?>
|
||||||
|
</td>
|
||||||
|
<td><?= $kindBadge($r) ?></td>
|
||||||
|
<td class="small muted"><?= e($srcWord($r)) ?></td>
|
||||||
|
<td class="nowrap">
|
||||||
|
<?php if (($r['source'] ?? 'custom') === 'custom' && $canWrite): ?>
|
||||||
|
<a class="link-quiet" href="<?= e(url('days/edit', ['id' => (int)$r['id']])) ?>">改</a>
|
||||||
|
<form method="post" action="<?= e(url('days/destroy')) ?>" class="res-inline">
|
||||||
|
<?= csrf_field() ?>
|
||||||
|
<input type="hidden" name="id" value="<?= (int)$r['id'] ?>">
|
||||||
|
<button class="btn btn-ghost btn-sm" type="submit"
|
||||||
|
onclick="return confirm('删掉这条纪念日?')">删</button>
|
||||||
|
</form>
|
||||||
|
<?php else: ?>
|
||||||
|
<span class="small muted">来自名册</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<p class="tip" style="margin-top:10px">
|
||||||
|
名册里那部分(生日)是**实时读出来的** —— 改生日,这里跟着变,不用重复维护。
|
||||||
|
所有纪念日都会作为**全天日程**出现在日历里。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
+4
-3
@@ -15,7 +15,7 @@
|
|||||||
*/
|
*/
|
||||||
$kinCount = count($kin);
|
$kinCount = count($kin);
|
||||||
// 生日事件上用简称,卡片才不会一行塞不下;长名字在详情页里出
|
// 生日事件上用简称,卡片才不会一行塞不下;长名字在详情页里出
|
||||||
$shortKind = ['id' => '身份证生日', 'solar' => '阳历生日', 'lunar' => '农历生日'];
|
$shortKind = \App\Birthday::KINDS;
|
||||||
$today = date('Y-m-d');
|
$today = date('Y-m-d');
|
||||||
?>
|
?>
|
||||||
<section class="hero">
|
<section class="hero">
|
||||||
@@ -159,14 +159,15 @@ $today = date('Y-m-d');
|
|||||||
<?php /* ==================== 往年今日 ==================== */ ?>
|
<?php /* ==================== 往年今日 ==================== */ ?>
|
||||||
<section class="section">
|
<section class="section">
|
||||||
<div class="page-head" style="margin-bottom:12px">
|
<div class="page-head" style="margin-bottom:12px">
|
||||||
|
<?php /* v24 第 19 条:只认「往年的今天」,不再退回同月 —— 见 home_memories() 的说明。 */ ?>
|
||||||
<h1 style="font-size:15px">
|
<h1 style="font-size:15px">
|
||||||
<?= !empty($memories['exact']) ? '往年今日' : '往年这个月' ?>
|
往年的今天
|
||||||
<span class="sub"><?= e(date('n 月 j 日')) ?></span>
|
<span class="sub"><?= e(date('n 月 j 日')) ?></span>
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<?php if (empty($memories['rows'])): ?>
|
<?php if (empty($memories['rows'])): ?>
|
||||||
<div class="card card-pad empty">
|
<div class="card card-pad empty">
|
||||||
<b><?= !empty($memories['exact']) ? '往年的这一天还没有记录' : '往年的这个月还没有记录' ?></b>
|
<b>往年的这一天还没有记录</b>
|
||||||
日子还长,等有了第一次,明年这时候就会自己冒出来。
|
日子还长,等有了第一次,明年这时候就会自己冒出来。
|
||||||
</div>
|
</div>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
|
|||||||
+79
-14
@@ -20,12 +20,18 @@ $people = $people ?? [];
|
|||||||
// familyMap 的 $up/$down 只影响**代数标号**(超出就压到边界那一档),
|
// familyMap 的 $up/$down 只影响**代数标号**(超出就压到边界那一档),
|
||||||
// 遍历本身是走遍整个连通图的 —— 所以这两个值不放宽的话,
|
// 遍历本身是走遍整个连通图的 —— 所以这两个值不放宽的话,
|
||||||
// 曾祖辈与祖辈会被压成同一代,排序就乱了。给足余量。
|
// 曾祖辈与祖辈会被压成同一代,排序就乱了。给足余量。
|
||||||
$map = \App\Kinship::familyMap((int)\App\Auth::familyId(), (int)$meId, $people, 9, 9);
|
// ⚠️ v24 第 12 条:中心是 $focusId(默认=我)。点姻亲入口进来时,中心换成那个人,
|
||||||
|
// 这样 TA 那一侧的姻亲(岳父母等)才画得出来。
|
||||||
|
$focusId = (int)($focusId ?? $meId);
|
||||||
|
if (!isset($people[$focusId]) || !is_array($people[$focusId])) {
|
||||||
|
$focusId = (int)$meId;
|
||||||
|
}
|
||||||
|
$map = \App\Kinship::familyMap((int)\App\Auth::familyId(), $focusId, $people, 9, 9);
|
||||||
$gen = $map['gen'];
|
$gen = $map['gen'];
|
||||||
$units = $map['units'];
|
$units = $map['units'];
|
||||||
$unitOf = $map['unit_of'];
|
$unitOf = $map['unit_of'];
|
||||||
|
|
||||||
if (!isset($people[$meId]) || !is_array($people[$meId])) {
|
if (!isset($people[$focusId]) || !is_array($people[$focusId])) {
|
||||||
$canChart = false;
|
$canChart = false;
|
||||||
} else {
|
} else {
|
||||||
$onTree = array_fill_keys(array_map('intval', array_keys($gen)), true);
|
$onTree = array_fill_keys(array_map('intval', array_keys($gen)), true);
|
||||||
@@ -37,7 +43,7 @@ if (!isset($people[$meId]) || !is_array($people[$meId])) {
|
|||||||
// 把树根换成我的最高祖先,整棵树就变成「这位祖先的全部后代」,
|
// 把树根换成我的最高祖先,整棵树就变成「这位祖先的全部后代」,
|
||||||
// 上一代、这一代、下一代的血亲与旁支才能全部落进来。
|
// 上一代、这一代、下一代的血亲与旁支才能全部落进来。
|
||||||
// 代价是「我」不再是几何中心 —— 用「回到我」按钮 + 加载后自动居中补回来。
|
// 代价是「我」不再是几何中心 —— 用「回到我」按钮 + 加载后自动居中补回来。
|
||||||
$rootId = (int)$meId;
|
$rootId = (int)$focusId;
|
||||||
for ($guard = 0; $guard < 8; $guard++) {
|
for ($guard = 0; $guard < 8; $guard++) {
|
||||||
$up = \App\Kinship::parentIds($graph, $rootId);
|
$up = \App\Kinship::parentIds($graph, $rootId);
|
||||||
$next = 0;
|
$next = 0;
|
||||||
@@ -108,7 +114,9 @@ if (!isset($people[$meId]) || !is_array($people[$meId])) {
|
|||||||
'name' => \App\Nickname::display((int)$meId, $p) ?: (string)($p['name'] ?? ''),
|
'name' => \App\Nickname::display((int)$meId, $p) ?: (string)($p['name'] ?? ''),
|
||||||
'label' => \App\Kinship::label($graph, (int)$meId, $pid, $people),
|
'label' => \App\Kinship::label($graph, (int)$meId, $pid, $people),
|
||||||
'avatar' => avatar_url($p['avatar'] ?? null),
|
'avatar' => avatar_url($p['avatar'] ?? null),
|
||||||
'me' => $pid === (int)$meId,
|
// 「我」在图上高亮;中心那个人另有标记($focusId)
|
||||||
|
'me' => $pid === (int)$focusId,
|
||||||
|
'is_me_viewer' => $pid === (int)$meId,
|
||||||
'ph' => (int)($p['is_placeholder'] ?? 0) === 1,
|
'ph' => (int)($p['is_placeholder'] ?? 0) === 1,
|
||||||
],
|
],
|
||||||
'rels' => [
|
'rels' => [
|
||||||
@@ -119,6 +127,16 @@ if (!isset($people[$meId]) || !is_array($people[$meId])) {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v24 第 12 条:本图上「有姻亲但没画出来」的人 —— 卡片上给个入口
|
||||||
|
$branchOuts = \App\Kinship::branchOuts($graph, $people, $onTree);
|
||||||
|
foreach ($datum as &$dn) {
|
||||||
|
$did = (int)$dn['id'];
|
||||||
|
if (isset($branchOuts[$did])) {
|
||||||
|
$dn['data']['branch'] = $branchOuts[$did]['name'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unset($dn);
|
||||||
|
|
||||||
$canChart = count($datum) > 1;
|
$canChart = count($datum) > 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,8 +148,18 @@ $datumJson = json_encode(
|
|||||||
?>
|
?>
|
||||||
|
|
||||||
<div class="page-head">
|
<div class="page-head">
|
||||||
<h1>我的家谱</h1>
|
<h1><?= $focusId === (int)$meId ? '我的家谱' : e((string)($people[$focusId]['name'] ?? '') . ' 的家谱') ?></h1>
|
||||||
<span class="sub">以我为中心 · 上下一共五代 · 夫妻并排,子女从两人之间的连线连出</span>
|
<span class="sub">
|
||||||
|
<?= $focusId === (int)$meId
|
||||||
|
? '以我为中心 · 夫妻并排,子女从两人之间的连线连出'
|
||||||
|
: '以 TA 为中心(这样 TA 那一侧的姻亲才露得出来)' ?>
|
||||||
|
</span>
|
||||||
|
<div class="actions">
|
||||||
|
<?php if ($focusId !== (int)$meId): ?>
|
||||||
|
<?php /* v24 第 12 条:从姻亲入口进来的,给一个返回,回到「我的家谱」 */ ?>
|
||||||
|
<a class="btn btn-ghost" href="<?= e(url('kin')) ?>">← 返回我的家谱</a>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<?php if (!$canChart): ?>
|
<?php if (!$canChart): ?>
|
||||||
@@ -144,7 +172,8 @@ $datumJson = json_encode(
|
|||||||
|
|
||||||
<div class="kin-tools">
|
<div class="kin-tools">
|
||||||
<button type="button" class="btn btn-ghost btn-sm" id="kinFit">看全部</button>
|
<button type="button" class="btn btn-ghost btn-sm" id="kinFit">看全部</button>
|
||||||
<button type="button" class="btn btn-ghost btn-sm" id="kinMe">回到我</button>
|
<?php /* 中心是别人时这颗按钮是"跳回我的家谱"(不是在本图里找我的坐标) */ ?>
|
||||||
|
<button type="button" class="btn btn-ghost btn-sm" id="kinMe"><?= $focusId === (int)$meId ? '回到我' : '回到我的家谱' ?></button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ⚠️ `f3` 这个类名是**库的 CSS 作用域**,不是我们起的名字。
|
<!-- ⚠️ `f3` 这个类名是**库的 CSS 作用域**,不是我们起的名字。
|
||||||
@@ -152,7 +181,8 @@ $datumJson = json_encode(
|
|||||||
少了它,库里 `.f3 svg.main_svg{width:100%;height:100%}` 这条撑满规则不生效,
|
少了它,库里 `.f3 svg.main_svg{width:100%;height:100%}` 这条撑满规则不生效,
|
||||||
SVG 会退化成浏览器默认的 300×150(连线全被裁掉,而 HTML 卡片层不受影响 →
|
SVG 会退化成浏览器默认的 300×150(连线全被裁掉,而 HTML 卡片层不受影响 →
|
||||||
表现为「卡在、线没了」)。 -->
|
表现为「卡在、线没了」)。 -->
|
||||||
<div class="kin-chart f3" id="kinChart" data-datum="<?= e($datumJson) ?>" data-me="<?= e((string)$meId) ?>"></div>
|
<div class="kin-chart f3" id="kinChart" data-datum="<?= e($datumJson) ?>"
|
||||||
|
data-focus="<?= e((string)$focusId) ?>" data-me-viewer="<?= e((string)$meId) ?>"></div>
|
||||||
|
|
||||||
<p class="tree-legend">
|
<p class="tree-legend">
|
||||||
每代一行;<b>一对夫妻之间有一条短横线</b>,
|
每代一行;<b>一对夫妻之间有一条短横线</b>,
|
||||||
@@ -197,22 +227,29 @@ $datumJson = json_encode(
|
|||||||
// 会跳过深度截断,等于**不限代数**,有多少画多少。
|
// 会跳过深度截断,等于**不限代数**,有多少画多少。
|
||||||
// 之前写死了具体代数,真实家庭一超过就被砍掉。
|
// 之前写死了具体代数,真实家庭一超过就被砍掉。
|
||||||
// 用户要求「全部取出来」,所以这里也不设。
|
// 用户要求「全部取出来」,所以这里也不设。
|
||||||
.setCardXSpacing(180)
|
.setCardXSpacing(190)
|
||||||
.setCardYSpacing(180);
|
.setCardYSpacing(180);
|
||||||
|
|
||||||
chart.setCardHtml()
|
chart.setCardHtml()
|
||||||
.setStyle('rect')
|
.setStyle('rect')
|
||||||
.setCardDim({ w: 140, h: 120, mini_tree: false })
|
.setCardDim({ w: 148, h: 138, mini_tree: false })
|
||||||
.setCardInnerHtmlCreator(function (d) {
|
.setCardInnerHtmlCreator(function (d) {
|
||||||
var p = (d.data && d.data.data) || {};
|
var p = (d.data && d.data.data) || {};
|
||||||
var id = (d.data && d.data.id) || '';
|
var id = (d.data && d.data.id) || '';
|
||||||
var cls = 'kin-card' + (p.me ? ' is-me' : '') + (p.ph ? ' is-ph' : '');
|
var cls = 'kin-card' + (p.me ? ' is-me' : '') + (p.ph ? ' is-ph' : '');
|
||||||
|
// v24 第 12 条:头像改**方形**(原来是圆角矩形 + cover,把脸裁掉了)。
|
||||||
|
// 卡片本身是 <a> 进成员页;有姻亲没画出来的,再叠一个独立的小按钮
|
||||||
|
// (用 <span role=button> 而不是 <a>:<a> 不能套 <a>)。
|
||||||
return '<a class="' + cls + '" data-kin-id="' + esc(id) + '" href="/index.php?r=people/show&id='
|
return '<a class="' + cls + '" data-kin-id="' + esc(id) + '" href="/index.php?r=people/show&id='
|
||||||
+ encodeURIComponent(id) + '">'
|
+ encodeURIComponent(id) + '">'
|
||||||
+ '<span class="kin-ph"><img src="' + esc(p.avatar) + '" alt=""></span>'
|
+ '<span class="kin-ph"><img src="' + esc(p.avatar) + '" alt=""></span>'
|
||||||
+ '<span class="kin-nm">' + esc(p.name) + '</span>'
|
+ '<span class="kin-nm">' + esc(p.name) + '</span>'
|
||||||
+ '<span class="kin-lb">' + esc(p.label) + '</span>'
|
+ '<span class="kin-lb">' + esc(p.label) + '</span>'
|
||||||
+ (p.ph ? '<span class="kin-mark">待确认</span>' : '')
|
+ (p.ph ? '<span class="kin-mark">待确认</span>' : '')
|
||||||
|
+ (p.branch
|
||||||
|
? '<span class="kin-branch" role="button" tabindex="0" title="看看 '
|
||||||
|
+ esc(p.branch) + ' 那一侧的家谱" data-branch-id="' + esc(id) + '">⇄ 姻亲</span>'
|
||||||
|
: '')
|
||||||
+ '</a>';
|
+ '</a>';
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -256,12 +293,15 @@ $datumJson = json_encode(
|
|||||||
}
|
}
|
||||||
window.kinCenterOn = centerOn;
|
window.kinCenterOn = centerOn;
|
||||||
|
|
||||||
var meId = root.getAttribute('data-me') || '';
|
var focusId = root.getAttribute('data-focus') || '';
|
||||||
|
var meId = root.getAttribute('data-me-viewer') || focusId;
|
||||||
|
// ⚠️ 中心是别人时,「回到我」要跳到 focus=我 的图,而不是在图里找我的坐标
|
||||||
|
var centerTarget = root.getAttribute('data-me-viewer') || focusId;
|
||||||
// fit 动画完成后把镜头挪到「我」(树根是最高祖先,我不再是几何中心)
|
// fit 动画完成后把镜头挪到「我」(树根是最高祖先,我不再是几何中心)
|
||||||
if (meId) {
|
if (meId) {
|
||||||
setTimeout(function () { centerOn(meId); }, 900);
|
setTimeout(function () { centerOn(focusId); }, 900);
|
||||||
// 万一 zoom 对象还没初始化好,再补一次
|
// 万一 zoom 对象还没初始化好,再补一次
|
||||||
setTimeout(function () { centerOn(meId); }, 1400);
|
setTimeout(function () { centerOn(focusId); }, 1400);
|
||||||
}
|
}
|
||||||
|
|
||||||
var btnFit = document.getElementById('kinFit');
|
var btnFit = document.getElementById('kinFit');
|
||||||
@@ -271,8 +311,33 @@ $datumJson = json_encode(
|
|||||||
chart.updateTree({ tree_position: 'fit' });
|
chart.updateTree({ tree_position: 'fit' });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
/* v24 第 12 条:卡片右下角的「⇄ 姻亲」按钮 —— 以那个人为中心重画一张家谱。
|
||||||
|
⚠️ 这个 span 在卡片的 <a> 里,必须 preventDefault + stopPropagation,
|
||||||
|
否则会先触发外面那个链接跳去成员页。 */
|
||||||
|
Array.prototype.forEach.call(root.querySelectorAll('.kin-branch'), function (btn) {
|
||||||
|
function go(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
var bid = btn.getAttribute('data-branch-id') || '';
|
||||||
|
if (bid) { window.location.href = '/index.php?r=kin&focus=' + encodeURIComponent(bid); }
|
||||||
|
}
|
||||||
|
btn.addEventListener('click', go);
|
||||||
|
btn.addEventListener('keydown', function (e) {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') { go(e); }
|
||||||
|
});
|
||||||
|
// 键盘/触摸都别让它冒泡到外层链接
|
||||||
|
btn.addEventListener('mousedown', function (e) { e.stopPropagation(); });
|
||||||
|
});
|
||||||
|
|
||||||
if (btnMe && meId) {
|
if (btnMe && meId) {
|
||||||
btnMe.addEventListener('click', function () { centerOn(meId); });
|
btnMe.addEventListener('click', function () {
|
||||||
|
// 图是别人的家谱时,先跳回「我的家谱」再居中
|
||||||
|
if (focusId !== meId) {
|
||||||
|
window.location.href = '/index.php?r=kin';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
centerOn(centerTarget);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -99,6 +99,20 @@ $isLoggedIn = \App\Auth::check();
|
|||||||
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<?php /* 全局附件预览层(v24 第 3 条):图片 / 视频 / PDF 都在本页这一层里看,
|
||||||
|
右上角恒有下载按钮。内容由 assets/js/preview.js 按 data-prev 现填。 */ ?>
|
||||||
|
<div class="pv" id="pv" role="dialog" aria-modal="true" aria-hidden="true" aria-label="附件预览" hidden>
|
||||||
|
<div class="pv-head">
|
||||||
|
<span class="pv-name"></span>
|
||||||
|
<span class="pv-meta"></span>
|
||||||
|
<a class="pv-dl" href="#">下载</a>
|
||||||
|
<button type="button" class="pv-close" aria-label="关闭">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="pv-stage"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script src="<?= e(asset('/assets/js/app.js')) ?>"></script>
|
<script src="<?= e(asset('/assets/js/app.js')) ?>"></script>
|
||||||
|
<script src="<?= e(asset('/assets/js/person_picker.js')) ?>"></script>
|
||||||
|
<script src="<?= e(asset('/assets/js/preview.js')) ?>"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -0,0 +1,288 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* 财务 / 账本(v24 用户第 15 条)
|
||||||
|
*
|
||||||
|
* 用户原话:「先创建一个家庭,或者说,创建一个账本,这个账本,可以共享给某个人,
|
||||||
|
* 共享后,这个人就可以往这个账本加入账目,这样,双方都可以看到所有的账目,
|
||||||
|
* 最后计算出总数,这就是一个家庭的总账目。」
|
||||||
|
*
|
||||||
|
* 页面结构就是照着这句话来的:
|
||||||
|
* 顶上一排账本(切换 / 新建)→ 汇总卡(资产 / 负债 / 净资产)→ 账目表 → 共享设置。
|
||||||
|
*
|
||||||
|
* ⚠️ 金额全程以**分**参与计算(Ledger 里做的),这里只负责显示成元。
|
||||||
|
* 任何地方都不要拿元去加总。
|
||||||
|
*
|
||||||
|
* @param array $books 我能看到的账本(带 my_perm / entry_count)
|
||||||
|
* @param array $cur 当前账本
|
||||||
|
* @param array $entries 当前账本的账目
|
||||||
|
* @param array $summary ['asset','debt','net','by_kind','count'](单位:分)
|
||||||
|
* @param array $shares 当前账本的共享列表
|
||||||
|
* @param array $people 名册
|
||||||
|
*/
|
||||||
|
$canWrite = \App\Auth::canWrite() && \App\Ledger::canWrite($cur);
|
||||||
|
$curId = (int)$cur['id'];
|
||||||
|
$money = static fn($c): string => \App\Ledger::money($c);
|
||||||
|
?>
|
||||||
|
<div class="page-head">
|
||||||
|
<h1>财务</h1>
|
||||||
|
<span class="sub">家庭的账 · 共享后双方都能记、都能看</span>
|
||||||
|
<div class="actions">
|
||||||
|
<a class="btn btn-ghost" href="<?= e(url('ledger', ['book' => $curId])) ?>">刷新</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if (count($books) > 1): ?>
|
||||||
|
<div class="seg">
|
||||||
|
<?php foreach ($books as $b): ?>
|
||||||
|
<a class="seg-btn<?= (int)$b['id'] === $curId ? ' is-on' : '' ?>"
|
||||||
|
href="<?= e(url('ledger', ['book' => (int)$b['id']])) ?>">
|
||||||
|
<?= e((string)$b['name']) ?><?= ($b['my_perm'] ?? '') === 'read' ? '(只读)' : '' ?>
|
||||||
|
</a>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<div class="card card-pad led-sum">
|
||||||
|
<div class="led-sum-head">
|
||||||
|
<h2 class="cinema-h2" style="margin:0"><?= e((string)$cur['name']) ?></h2>
|
||||||
|
<span class="small muted">
|
||||||
|
<?= (int)($cur['entry_count'] ?? count($entries)) ?> 笔账
|
||||||
|
<?php if (($cur['my_perm'] ?? '') === 'read'): ?>
|
||||||
|
· <b>只读</b>(不能记账)
|
||||||
|
<?php endif; ?>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="led-total">
|
||||||
|
<div class="led-total-item">
|
||||||
|
<span class="led-total-lb">总资产</span>
|
||||||
|
<span class="led-total-num led-asset"><?= e($money($summary['asset'])) ?></span>
|
||||||
|
</div>
|
||||||
|
<div class="led-total-item">
|
||||||
|
<span class="led-total-lb">总负债</span>
|
||||||
|
<span class="led-total-num led-debt"><?= e($money($summary['debt'])) ?></span>
|
||||||
|
</div>
|
||||||
|
<div class="led-total-item led-net">
|
||||||
|
<span class="led-total-lb">净资产</span>
|
||||||
|
<span class="led-total-num"><?= e($money($summary['net'])) ?></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php if ($summary['by_kind']): ?>
|
||||||
|
<div class="led-kinds">
|
||||||
|
<?php foreach ($summary['by_kind'] as $k => $c): ?>
|
||||||
|
<span class="led-kind-chip<?= \App\Ledger::isDebt((string)$k) ? ' is-debt' : '' ?>">
|
||||||
|
<?= \App\Ledger::kindIcon((string)$k) ?> <?= e(\App\Ledger::kindWord((string)$k)) ?>
|
||||||
|
<b><?= e($money($c)) ?></b>
|
||||||
|
</span>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if ($canWrite): ?>
|
||||||
|
<div class="card card-pad">
|
||||||
|
<h2 class="cinema-h2">记一笔</h2>
|
||||||
|
<form method="post" action="<?= e(url('ledger/entry_add')) ?>">
|
||||||
|
<?= csrf_field() ?>
|
||||||
|
<input type="hidden" name="ledger_id" value="<?= $curId ?>">
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="field">
|
||||||
|
<label for="le-kind">类别 *</label>
|
||||||
|
<select class="select" id="le-kind" name="kind">
|
||||||
|
<?php foreach (\App\Ledger::KINDS as $k => $v): ?>
|
||||||
|
<option value="<?= e($k) ?>"><?= e($v[2] . ' ' . $v[0]) ?><?= $v[1] === 'debt' ? '(负债)' : '' ?></option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="le-title">名称 *</label>
|
||||||
|
<input class="input" id="le-title" name="title" required placeholder="如:工商银行定期">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="le-amount">金额(元)*</label>
|
||||||
|
<input class="input" id="le-amount" name="amount" required inputmode="decimal"
|
||||||
|
placeholder="如:120000.00">
|
||||||
|
<span class="tip">负债(贷款/信用卡)也填正数,系统按类别算成负债。</span>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="le-person">挂在谁名下</label>
|
||||||
|
<select class="select" id="le-person" name="person_id">
|
||||||
|
<option value="0">不指定</option>
|
||||||
|
<?php foreach ($people as $p): ?>
|
||||||
|
<option value="<?= (int)$p['id'] ?>"><?= e(person_name($p)) ?></option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field full">
|
||||||
|
<label for="le-note">备注</label>
|
||||||
|
<input class="input" id="le-note" name="note" placeholder="可选">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="btn-row"><button class="btn" type="submit">添加账目</button></div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<div class="card card-pad">
|
||||||
|
<h2 class="cinema-h2">账目(<?= count($entries) ?>)</h2>
|
||||||
|
<?php if (!$entries): ?>
|
||||||
|
<p class="empty-hint">这本账还没有账目。上面「记一笔」开始记吧。</p>
|
||||||
|
<?php else: ?>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>类别</th><th>名称</th><th class="num">金额</th><th>挂在谁名下</th><th>备注</th><th></th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php foreach ($entries as $en): ?>
|
||||||
|
<?php $ek = (string)$en['kind']; $debt = \App\Ledger::isDebt($ek); ?>
|
||||||
|
<tr>
|
||||||
|
<td class="nowrap">
|
||||||
|
<span class="led-kind-chip<?= $debt ? ' is-debt' : '' ?>">
|
||||||
|
<?= \App\Ledger::kindIcon($ek) ?> <?= e(\App\Ledger::kindWord($ek)) ?>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td><?= e((string)$en['title']) ?></td>
|
||||||
|
<td class="num<?= $debt ? ' led-debt' : ' led-asset' ?>">
|
||||||
|
<?= $debt ? '-' : '' ?><?= e($money((int)$en['amount_cents'])) ?>
|
||||||
|
</td>
|
||||||
|
<td class="nowrap">
|
||||||
|
<?php if (!empty($en['person_name'])): ?>
|
||||||
|
<?= person_chip([
|
||||||
|
'id' => (int)$en['person_id'], 'name' => (string)$en['person_name'],
|
||||||
|
'my_nickname' => (string)($en['person_nick'] ?? ''),
|
||||||
|
'avatar' => $en['avatar'] ?? null, 'avatar_color' => $en['avatar_color'] ?? null,
|
||||||
|
]) ?>
|
||||||
|
<?php else: ?><span class="small muted">—</span><?php endif; ?>
|
||||||
|
</td>
|
||||||
|
<td class="small muted"><?= e((string)($en['note'] ?? '')) ?></td>
|
||||||
|
<td class="nowrap">
|
||||||
|
<?php if ($canWrite): ?>
|
||||||
|
<details class="led-edit">
|
||||||
|
<summary class="link-quiet">改</summary>
|
||||||
|
<form method="post" action="<?= e(url('ledger/entry_update')) ?>" class="node-form">
|
||||||
|
<?= csrf_field() ?>
|
||||||
|
<input type="hidden" name="ledger_id" value="<?= $curId ?>">
|
||||||
|
<input type="hidden" name="id" value="<?= (int)$en['id'] ?>">
|
||||||
|
<div class="nf-grid">
|
||||||
|
<div class="field">
|
||||||
|
<label>类别</label>
|
||||||
|
<select class="select" name="kind">
|
||||||
|
<?php foreach (\App\Ledger::KINDS as $k => $v): ?>
|
||||||
|
<option value="<?= e($k) ?>" <?= $ek === $k ? 'selected' : '' ?>>
|
||||||
|
<?= e($v[0]) ?>
|
||||||
|
</option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>名称</label>
|
||||||
|
<input class="input" name="title" value="<?= e((string)$en['title']) ?>" required>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>金额(元)</label>
|
||||||
|
<input class="input" name="amount"
|
||||||
|
value="<?= e(\App\Ledger::amountYuan((int)$en['amount_cents'])) ?>" required>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>备注</label>
|
||||||
|
<input class="input" name="note" value="<?= e((string)($en['note'] ?? '')) ?>">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="btn-row"><button class="btn btn-sm" type="submit">保存</button></div>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="<?= e(url('ledger/entry_del')) ?>" class="res-inline">
|
||||||
|
<?= csrf_field() ?>
|
||||||
|
<input type="hidden" name="ledger_id" value="<?= $curId ?>">
|
||||||
|
<input type="hidden" name="id" value="<?= (int)$en['id'] ?>">
|
||||||
|
<button class="btn btn-ghost btn-sm" type="submit"
|
||||||
|
onclick="return confirm('删掉这笔账目?')">删除</button>
|
||||||
|
</form>
|
||||||
|
</details>
|
||||||
|
<?php endif; ?>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr>
|
||||||
|
<th colspan="2">合计(资产 − 负债)</th>
|
||||||
|
<th class="num led-net"><?= e($money($summary['net'])) ?></th>
|
||||||
|
<th colspan="3"></th>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card card-pad">
|
||||||
|
<h2 class="cinema-h2">共享与账本</h2>
|
||||||
|
<div class="led-shares">
|
||||||
|
<span class="res-label">已共享给</span>
|
||||||
|
<?php if (!$shares): ?>
|
||||||
|
<span class="small muted">还没有共享给任何人。</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php foreach ($shares as $s): ?>
|
||||||
|
<span class="led-share-chip">
|
||||||
|
<?= person_chip([
|
||||||
|
'id' => (int)$s['person_id'], 'name' => (string)$s['name'],
|
||||||
|
'avatar' => $s['avatar'] ?? null, 'avatar_color' => $s['avatar_color'] ?? null,
|
||||||
|
]) ?>
|
||||||
|
<span class="node-badge"><?= ($s['perm'] ?? 'read') === 'write' ? '可写' : '只读' ?></span>
|
||||||
|
<?php if ($canWrite): ?>
|
||||||
|
<form method="post" action="<?= e(url('ledger/unshare')) ?>" class="res-inline">
|
||||||
|
<?= csrf_field() ?>
|
||||||
|
<input type="hidden" name="ledger_id" value="<?= $curId ?>">
|
||||||
|
<input type="hidden" name="person_id" value="<?= (int)$s['person_id'] ?>">
|
||||||
|
<button class="btn btn-ghost btn-sm" type="submit">取消</button>
|
||||||
|
</form>
|
||||||
|
<?php endif; ?>
|
||||||
|
</span>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if ($canWrite): ?>
|
||||||
|
<div class="led-ops">
|
||||||
|
<?php
|
||||||
|
$ppName = 'person_id';
|
||||||
|
$ppPeople = $people;
|
||||||
|
$ppSelected = [];
|
||||||
|
$ppMode = 'single';
|
||||||
|
$ppOpenBtn = '选择共享给谁';
|
||||||
|
$ppTitle = '账本共享给谁';
|
||||||
|
$ppEmptyText = '未选择';
|
||||||
|
?>
|
||||||
|
<form class="inline-form" method="post" action="<?= e(url('ledger/share')) ?>">
|
||||||
|
<?= csrf_field() ?>
|
||||||
|
<input type="hidden" name="ledger_id" value="<?= $curId ?>">
|
||||||
|
<?php require __DIR__ . '/../_person_picker.php'; ?>
|
||||||
|
<select class="select" name="perm">
|
||||||
|
<?php foreach (\App\Ledger::PERM as $pk => $pv): ?>
|
||||||
|
<option value="<?= e($pk) ?>"><?= e($pv) ?></option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
<button class="btn btn-sm" type="submit">共享</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form class="inline-form" method="post" action="<?= e(url('ledger/book_rename')) ?>">
|
||||||
|
<?= csrf_field() ?>
|
||||||
|
<input type="hidden" name="ledger_id" value="<?= $curId ?>">
|
||||||
|
<input class="input" name="name" value="<?= e((string)$cur['name']) ?>" required>
|
||||||
|
<button class="btn btn-ghost btn-sm" type="submit">账本改名</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form class="inline-form" method="post" action="<?= e(url('ledger/book_add')) ?>">
|
||||||
|
<?= csrf_field() ?>
|
||||||
|
<input class="input" name="name" placeholder="新账本名,如:孩子的教育金" required>
|
||||||
|
<button class="btn btn-ghost btn-sm" type="submit">新建账本</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form class="inline-form" method="post" action="<?= e(url('ledger/book_del')) ?>">
|
||||||
|
<?= csrf_field() ?>
|
||||||
|
<input type="hidden" name="ledger_id" value="<?= $curId ?>">
|
||||||
|
<button class="btn btn-ghost btn-sm" type="submit"
|
||||||
|
onclick="return confirm('删掉这本账?里面的账目会一起删掉。')">删除这本账</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
+14
-163
@@ -105,44 +105,21 @@ $selAttendees = array_map('intval', (array)old('attendees', $curAttendees));
|
|||||||
|
|
||||||
<div class="field full">
|
<div class="field full">
|
||||||
<label>同行的人</label>
|
<label>同行的人</label>
|
||||||
<?php /* v10:名册人多时 checkbox 网格会拉得很长 —— 改为「chips + 弹窗选择」。
|
<?php /* v24 第 11 条:换成全局人员选择器(templates/_person_picker.php)。
|
||||||
attendees[] 的 checkbox 仍保留(在弹窗列表里,选中即提交),无 JS 时
|
原来这套是观影页私有的 .att-*,它的「过滤」其实一直是坏的 ——
|
||||||
借 noscript 样式把弹窗平铺成普通勾选列表,提交语义完全不变。
|
.att-item 自己写了 display:flex,压过了浏览器的 [hidden]{display:none},
|
||||||
v11:chips 与弹窗列表都带上头像($p 来自 Person::roster,含 p.avatar)。 */ ?>
|
JS 里 el.hidden = true 根本没生效,搜什么都还是一整列。 */ ?>
|
||||||
<?php if ($people): ?>
|
<?php if ($people): ?>
|
||||||
<div class="att-chips" id="attChips">
|
<?php
|
||||||
<?php foreach ($people as $p): ?>
|
$ppName = 'attendees[]';
|
||||||
<?php if (in_array((int)$p['id'], $selAttendees, true)): ?>
|
$ppPeople = $people;
|
||||||
<span class="att-chip" data-id="<?= (int)$p['id'] ?>"><img src="<?= e(avatar_url($p['avatar'] ?? null)) ?>" alt=""><span class="att-name"><?= e($p['shown'] ?? $p['name']) ?></span><button type="button" class="att-x" data-id="<?= (int)$p['id'] ?>" title="移除">×</button></span>
|
$ppSelected = $selAttendees;
|
||||||
<?php endif; ?>
|
$ppMode = 'multi';
|
||||||
<?php endforeach; ?>
|
$ppOpenBtn = '+ 添加观影人';
|
||||||
</div>
|
$ppTitle = '选择观影人';
|
||||||
<button type="button" class="btn btn-ghost btn-sm att-open-btn" id="attOpen">+ 添加观影人</button>
|
$ppTip = '';
|
||||||
<div class="att-modal" id="attModal" hidden>
|
require __DIR__ . '/../_person_picker.php';
|
||||||
<div class="att-modal-box">
|
?>
|
||||||
<div class="att-modal-head">
|
|
||||||
<b>选择观影人</b>
|
|
||||||
<button type="button" class="att-close" id="attClose" title="关闭">×</button>
|
|
||||||
</div>
|
|
||||||
<input class="input" id="attSearch" type="text"
|
|
||||||
placeholder="输入名字或拼音首字母,如 zy 找「张三」" autocomplete="off">
|
|
||||||
<div class="att-list" id="attList">
|
|
||||||
<?php foreach ($people as $p): ?>
|
|
||||||
<label class="att-item"
|
|
||||||
data-name="<?= e((string)$p['name']) ?>"
|
|
||||||
data-shown="<?= e((string)($p['shown'] ?? '')) ?>">
|
|
||||||
<input type="checkbox" name="attendees[]" value="<?= (int)$p['id'] ?>"
|
|
||||||
<?= in_array((int)$p['id'], $selAttendees, true) ? 'checked' : '' ?>>
|
|
||||||
<img class="att-av" src="<?= e(avatar_url($p['avatar'] ?? null)) ?>" alt="" loading="lazy">
|
|
||||||
<span class="att-name"><?= e($p['shown'] ?? $p['name']) ?></span>
|
|
||||||
<span class="att-initials" hidden></span>
|
|
||||||
</label>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
<div class="att-empty" hidden>没有匹配的人。</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<noscript><style>.att-open-btn { display: none; } .att-modal { position: static; display: block; background: none; padding: 0; } .att-modal-box { box-shadow: none; border: 1px solid var(--line); max-height: none; }</style></noscript>
|
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<span class="tip">名册里还没有人,先把家人加进名册再来记。</span>
|
<span class="tip">名册里还没有人,先把家人加进名册再来记。</span>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
@@ -192,131 +169,6 @@ $selAttendees = array_map('intval', (array)old('attendees', $curAttendees));
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<?php if ($people): ?>
|
|
||||||
<!-- 观影人选择弹窗:chips 同步 + 拼音首字母联想(Intl pinyin 排序 + 23 锚字二分,零依赖) -->
|
|
||||||
<script>
|
|
||||||
(function () {
|
|
||||||
var modal = document.getElementById('attModal');
|
|
||||||
if (!modal) return;
|
|
||||||
var openBtn = document.getElementById('attOpen');
|
|
||||||
var closeBtn = document.getElementById('attClose');
|
|
||||||
var search = document.getElementById('attSearch');
|
|
||||||
var list = document.getElementById('attList');
|
|
||||||
var chips = document.getElementById('attChips');
|
|
||||||
var emptyTip = list.querySelector('.att-empty');
|
|
||||||
|
|
||||||
/* ---- 汉字 → 拼音首字母:锚字表 a..z(23 个,无 i/u/v 开头字),
|
|
||||||
用拼音 collation 做二分定位;不支持的浏览器返回 null(只按名字搜) ---- */
|
|
||||||
var initialsOf = (function () {
|
|
||||||
var anchors = '阿八擦搭娥发嘎哈击喀垃妈拿哦啪期然撒塌挖昔压匝';
|
|
||||||
var letters = 'abcdefghjklmnopqrstwxyz';
|
|
||||||
var coll;
|
|
||||||
try { coll = new Intl.Collator('zh-Hans-CN-u-co-pinyin'); } catch (e) { return null; }
|
|
||||||
if (!coll || coll.compare('阿', '匝') >= 0) return null; // collation 没生效
|
|
||||||
return function (s) {
|
|
||||||
var out = '';
|
|
||||||
for (var i = 0; i < s.length; i++) {
|
|
||||||
var ch = s[i];
|
|
||||||
if (/[a-zA-Z]/.test(ch)) { out += ch.toUpperCase(); continue; }
|
|
||||||
if (ch.charCodeAt(0) < 0x2E80) continue; // 非 CJK:不产字母
|
|
||||||
var lo = 0, hi = anchors.length - 1, best = -1;
|
|
||||||
while (lo <= hi) {
|
|
||||||
var mid = (lo + hi) >> 1;
|
|
||||||
if (coll.compare(ch, anchors[mid]) >= 0) { best = mid; lo = mid + 1; }
|
|
||||||
else { hi = mid - 1; }
|
|
||||||
}
|
|
||||||
if (best >= 0) out += letters[best];
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
};
|
|
||||||
})();
|
|
||||||
|
|
||||||
/* 预计算每个人的首字母串,挂在 data-initials 上 */
|
|
||||||
Array.prototype.forEach.call(list.querySelectorAll('.att-item'), function (el) {
|
|
||||||
var s = (el.getAttribute('data-name') || '') + (el.getAttribute('data-shown') || '');
|
|
||||||
var ini = initialsOf ? initialsOf(s) : '';
|
|
||||||
el.setAttribute('data-initials', ini.toLowerCase());
|
|
||||||
var badge = el.querySelector('.att-initials');
|
|
||||||
if (badge && ini) { badge.textContent = ini; badge.hidden = false; }
|
|
||||||
});
|
|
||||||
|
|
||||||
function syncChips() {
|
|
||||||
if (!chips) return;
|
|
||||||
chips.innerHTML = '';
|
|
||||||
Array.prototype.forEach.call(list.querySelectorAll('.att-item input:checked'), function (box) {
|
|
||||||
var item = box.closest('.att-item');
|
|
||||||
// ⚠️ 必须点名取 .att-name:v11 起 .att-item 里第一个元素是头像 <img>,
|
|
||||||
// 早先靠「取第一个 span 当名字」碰巧成立,现在第一个 span 是隐藏的
|
|
||||||
// 首字母占位,选完人 chip 上就没名字了。
|
|
||||||
var nameEl = item.querySelector('.att-name');
|
|
||||||
var label = nameEl ? nameEl.textContent : box.value;
|
|
||||||
var av = item.querySelector('.att-av');
|
|
||||||
var id = box.value;
|
|
||||||
var chip = document.createElement('span');
|
|
||||||
chip.className = 'att-chip';
|
|
||||||
chip.setAttribute('data-id', id);
|
|
||||||
if (av) {
|
|
||||||
var im = document.createElement('img');
|
|
||||||
im.src = av.getAttribute('src'); // 服务端已 e() 过,这里原样搬
|
|
||||||
im.alt = '';
|
|
||||||
chip.appendChild(im);
|
|
||||||
}
|
|
||||||
var nm = document.createElement('span');
|
|
||||||
nm.className = 'att-name';
|
|
||||||
nm.textContent = label;
|
|
||||||
chip.appendChild(nm);
|
|
||||||
var x = document.createElement('button');
|
|
||||||
x.type = 'button'; x.className = 'att-x'; x.textContent = '×';
|
|
||||||
x.title = '移除'; x.setAttribute('data-id', id);
|
|
||||||
chip.appendChild(x);
|
|
||||||
chips.appendChild(chip);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
chips.addEventListener('click', function (e) {
|
|
||||||
var x = e.target.closest ? e.target.closest('.att-x') : null;
|
|
||||||
if (!x) return;
|
|
||||||
var id = x.getAttribute('data-id');
|
|
||||||
Array.prototype.forEach.call(list.querySelectorAll('.att-item input'), function (box) {
|
|
||||||
if (box.value === id) box.checked = false;
|
|
||||||
});
|
|
||||||
syncChips();
|
|
||||||
});
|
|
||||||
|
|
||||||
function applyFilter() {
|
|
||||||
var q = (search.value || '').trim().toLowerCase();
|
|
||||||
var shown = 0;
|
|
||||||
Array.prototype.forEach.call(list.querySelectorAll('.att-item'), function (el) {
|
|
||||||
var hit = !q ||
|
|
||||||
(el.getAttribute('data-name') || '').toLowerCase().indexOf(q) >= 0 ||
|
|
||||||
(el.getAttribute('data-shown') || '').toLowerCase().indexOf(q) >= 0 ||
|
|
||||||
(el.getAttribute('data-initials') || '').indexOf(q) >= 0;
|
|
||||||
el.hidden = !hit;
|
|
||||||
if (hit) shown++;
|
|
||||||
});
|
|
||||||
emptyTip.hidden = shown > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function openModal() { modal.hidden = false; search.value = ''; applyFilter(); syncChips(); search.focus(); }
|
|
||||||
function closeModal() { modal.hidden = true; }
|
|
||||||
openBtn.addEventListener('click', openModal);
|
|
||||||
closeBtn.addEventListener('click', closeModal);
|
|
||||||
modal.addEventListener('click', function (e) { if (e.target === modal) closeModal(); });
|
|
||||||
document.addEventListener('keydown', function (e) {
|
|
||||||
if (e.key === 'Escape' && !modal.hidden) closeModal();
|
|
||||||
});
|
|
||||||
search.addEventListener('input', applyFilter);
|
|
||||||
|
|
||||||
/* 选一个 = 勾上 + 出 chips + 关弹窗(再添加就再点一次「+ 添加观影人」);
|
|
||||||
在弹窗里取消勾选只同步 chips,不关弹窗 */
|
|
||||||
list.addEventListener('change', function (e) {
|
|
||||||
if (!e.target || e.target.tagName !== 'INPUT') return;
|
|
||||||
syncChips();
|
|
||||||
if (e.target.checked) closeModal();
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<!-- 豆瓣自动补充:下拉联想(320ms 防抖 + 中文输入法组字期不请求)+ 一键抓取 -->
|
<!-- 豆瓣自动补充:下拉联想(320ms 防抖 + 中文输入法组字期不请求)+ 一键抓取 -->
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
@@ -454,4 +306,3 @@ $selAttendees = array_map('intval', (array)old('attendees', $curAttendees));
|
|||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
<?php endif; ?>
|
|
||||||
|
|||||||
@@ -35,15 +35,19 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="person_id">观影人</label>
|
<label>观影人</label>
|
||||||
<select class="select" id="person_id" name="person_id">
|
<?php /* 筛选用的单选:要留一个「不选」= 全部,所以开 $ppAllowEmpty */ ?>
|
||||||
<option value="0">全部观影人</option>
|
<?php
|
||||||
<?php foreach ($persons as $pp): ?>
|
$ppName = 'person_id';
|
||||||
<option value="<?= (int)$pp['id'] ?>" <?= (int)$p['person_id'] === (int)$pp['id'] ? 'selected' : '' ?>>
|
$ppPeople = $persons;
|
||||||
<?= e((string)$pp['name']) ?>
|
$ppSelected = [(int)$p['person_id']];
|
||||||
</option>
|
$ppMode = 'single';
|
||||||
<?php endforeach; ?>
|
$ppAllowEmpty = true;
|
||||||
</select>
|
$ppOpenBtn = '选择观影人';
|
||||||
|
$ppTitle = '按观影人筛选';
|
||||||
|
$ppEmptyText = '全部观影人';
|
||||||
|
require __DIR__ . '/../_person_picker.php';
|
||||||
|
?>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>日期范围</label>
|
<label>日期范围</label>
|
||||||
|
|||||||
@@ -422,6 +422,8 @@ $commentBox = static function (int $reviewId, int $parentId, string $summary) us
|
|||||||
<?php if ($hasPoster || $hasTicket || $m['photos']): ?>
|
<?php if ($hasPoster || $hasTicket || $m['photos']): ?>
|
||||||
<!-- 图片灯箱:点任意一张图在当前页弹出,可左右翻看 / Esc 关闭(MovieLog 同款) -->
|
<!-- 图片灯箱:点任意一张图在当前页弹出,可左右翻看 / Esc 关闭(MovieLog 同款) -->
|
||||||
<div class="lightbox" id="lightbox" role="dialog" aria-modal="true" aria-hidden="true" aria-label="图片查看器">
|
<div class="lightbox" id="lightbox" role="dialog" aria-modal="true" aria-hidden="true" aria-label="图片查看器">
|
||||||
|
<?php /* v24 第 3 条:预览层右上角恒有下载按钮(与全局附件预览一个规矩) */ ?>
|
||||||
|
<a class="lb-btn lb-dl" id="lbDl" href="#" download>下载</a>
|
||||||
<button type="button" class="lb-btn lb-close" id="lbClose" aria-label="关闭">✕</button>
|
<button type="button" class="lb-btn lb-close" id="lbClose" aria-label="关闭">✕</button>
|
||||||
<button type="button" class="lb-btn lb-nav lb-prev" id="lbPrev" aria-label="上一张">‹</button>
|
<button type="button" class="lb-btn lb-nav lb-prev" id="lbPrev" aria-label="上一张">‹</button>
|
||||||
<button type="button" class="lb-btn lb-nav lb-next" id="lbNext" aria-label="下一张">›</button>
|
<button type="button" class="lb-btn lb-nav lb-next" id="lbNext" aria-label="下一张">›</button>
|
||||||
@@ -449,12 +451,17 @@ $commentBox = static function (int $reviewId, int $parentId, string $summary) us
|
|||||||
var capEl = document.getElementById('lbCaption');
|
var capEl = document.getElementById('lbCaption');
|
||||||
var cntEl = document.getElementById('lbCounter');
|
var cntEl = document.getElementById('lbCounter');
|
||||||
var closeEl = document.getElementById('lbClose');
|
var closeEl = document.getElementById('lbClose');
|
||||||
|
var dlEl = document.getElementById('lbDl');
|
||||||
var prevEl = document.getElementById('lbPrev');
|
var prevEl = document.getElementById('lbPrev');
|
||||||
var nextEl = document.getElementById('lbNext');
|
var nextEl = document.getElementById('lbNext');
|
||||||
if (!box || !items.length) return;
|
if (!box || !items.length) return;
|
||||||
var idx = 0, opener = null;
|
var idx = 0, opener = null;
|
||||||
function render() {
|
function render() {
|
||||||
imgEl.src = items[idx].url;
|
imgEl.src = items[idx].url;
|
||||||
|
// 下载地址:与预览同一个键,只是带上 dl=1(服务端会改 Content-Disposition)
|
||||||
|
if (dlEl) {
|
||||||
|
dlEl.setAttribute('href', items[idx].url + (items[idx].url.indexOf('?') < 0 ? '?' : '&') + 'dl=1');
|
||||||
|
}
|
||||||
capEl.textContent = items[idx].cap;
|
capEl.textContent = items[idx].cap;
|
||||||
cntEl.textContent = (idx + 1) + ' / ' + items.length;
|
cntEl.textContent = (idx + 1) + ' / ' + items.length;
|
||||||
var multi = items.length > 1;
|
var multi = items.length > 1;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
* 三处与早期版本不同的地方:
|
* 三处与早期版本不同的地方:
|
||||||
* · 「小名」没了 —— 称呼是**视角属性**(妈妈叫"明明"、姐姐叫"小弟"),
|
* · 「小名」没了 —— 称呼是**视角属性**(妈妈叫"明明"、姐姐叫"小弟"),
|
||||||
* 在「成员详情」页里各自设置自己怎么叫,不写在这个人的档案里。
|
* 在「成员详情」页里各自设置自己怎么叫,不写在这个人的档案里。
|
||||||
* · 生日变成**三个**:身份证生日(由证件推导、只读)、阳历生日、农历生日。
|
* · 生日有两种记法:阳历生日与农历生日(证件上那天照常存库,但按用户要求不再显示)。
|
||||||
* 阳历与农历是同一个人的同一天,所以用「以哪个为准」明确这次填的是哪一个,
|
* 阳历与农历是同一个人的同一天,所以用「以哪个为准」明确这次填的是哪一个,
|
||||||
* 服务端据此反推另一个 —— 不靠 JS,避免"JS 没跑到就静默存错"。
|
* 服务端据此反推另一个 —— 不靠 JS,避免"JS 没跑到就静默存错"。
|
||||||
* · 生日精确到**时分秒**:原生 datetime-local 只有时分、且中文环境会显示成
|
* · 生日精确到**时分秒**:原生 datetime-local 只有时分、且中文环境会显示成
|
||||||
@@ -28,7 +28,6 @@ if ($lunVal === '') {
|
|||||||
$lv = (string)($person['birthday_lunar'] ?? '');
|
$lv = (string)($person['birthday_lunar'] ?? '');
|
||||||
$lunVal = $lv;
|
$lunVal = $lv;
|
||||||
}
|
}
|
||||||
$idBd = trim((string)($person['birthday_id_card'] ?? ''));
|
|
||||||
// 补充联系方式(v6 起):新增时是空数组;编辑时是库里解密后的行;
|
// 补充联系方式(v6 起):新增时是空数组;编辑时是库里解密后的行;
|
||||||
// 校验失败重画时是用户刚提交的那批(连同填错的也要原样带回)。
|
// 校验失败重画时是用户刚提交的那批(连同填错的也要原样带回)。
|
||||||
$contacts = $contacts ?? [];
|
$contacts = $contacts ?? [];
|
||||||
@@ -165,8 +164,7 @@ $ctHints = [
|
|||||||
value="<?= e(old('id_card', $idCardValue ?? '')) ?>" placeholder="可留空">
|
value="<?= e(old('id_card', $idCardValue ?? '')) ?>" placeholder="可留空">
|
||||||
<span class="tip">
|
<span class="tip">
|
||||||
加密存储,页面上一律只显示前 6 后 4 位。
|
加密存储,页面上一律只显示前 6 后 4 位。
|
||||||
<b>填了它,最下面的「身份证生日」会自动读出来</b>(当前:<span id="idCardBirthday"><?= $idBd !== '' ? e($idBd) : '' ?></span>),
|
家庭场景里真正需要它的场合很少 —— 能不填就别填。
|
||||||
这个日期系统永远不会改动。家庭场景里真正需要它的场合很少 —— 能不填就别填。
|
|
||||||
</span>
|
</span>
|
||||||
<?php if (!empty($errors['id_card'])): ?><span class="err"><?= e($errors['id_card']) ?></span><?php endif; ?>
|
<?php if (!empty($errors['id_card'])): ?><span class="err"><?= e($errors['id_card']) ?></span><?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
@@ -230,16 +228,10 @@ $ctHints = [
|
|||||||
<div class="field full">
|
<div class="field full">
|
||||||
<label>生日</label>
|
<label>生日</label>
|
||||||
<p class="small muted" style="margin:0 0 12px">
|
<p class="small muted" style="margin:0 0 12px">
|
||||||
一个人有三个生日:<b>身份证上的</b>(由证件推导,系统永远不会改它)、
|
一个人的生日分两种记法:<b>真实阳历生日</b>与<b>农历生日</b>。
|
||||||
<b>真实阳历生日</b>、<b>农历生日</b>。填了身份证号会自动读出第一个;
|
阳历与农历只要填一个,另一个自动算出来。两个不在同一天时,首页会分别提醒。
|
||||||
阳历与农历只要填一个,另一个自动算出来。三个不在同一天时,首页会分别提醒。
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="field" style="margin-bottom:12px">
|
|
||||||
<label for="birthday_id_card_display">身份证生日(只读)</label>
|
|
||||||
<span class="bd-readonly" id="birthday_id_card_display"><?= $idBd !== '' ? e($idBd) : '填了身份证号后自动读出' ?></span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="bd-source">
|
<div class="bd-source">
|
||||||
<label for="bdSource">我这次填的是</label>
|
<label for="bdSource">我这次填的是</label>
|
||||||
<select class="select" id="bdSource" name="birthday_source" style="width:auto">
|
<select class="select" id="bdSource" name="birthday_source" style="width:auto">
|
||||||
|
|||||||
@@ -34,13 +34,14 @@ $theirParents = \App\Kinship::parents($graph, $pid);
|
|||||||
$theirSpouses = \App\Kinship::spouseIds($graph, $pid);
|
$theirSpouses = \App\Kinship::spouseIds($graph, $pid);
|
||||||
$theirKids = \App\Kinship::childIds($graph, $pid);
|
$theirKids = \App\Kinship::childIds($graph, $pid);
|
||||||
$theirSibs = \App\Kinship::siblingIds($graph, $pid);
|
$theirSibs = \App\Kinship::siblingIds($graph, $pid);
|
||||||
$shortKind = ['id' => '身份证生日', 'solar' => '阳历生日', 'lunar' => '农历生日'];
|
$shortKind = \App\Birthday::KINDS;
|
||||||
?>
|
?>
|
||||||
|
|
||||||
<div class="page-head">
|
<div class="page-head">
|
||||||
<h1><?= e($pShown) ?></h1>
|
<h1><?= e($pShown) ?></h1>
|
||||||
<span class="sub">
|
<span class="sub">
|
||||||
<?php if (!$isSelf): ?>
|
<?php if (!$isSelf && (string)$labelToMe !== ''): ?>
|
||||||
|
<?php /* v24:没关系就不显示标签(Kinship::label() 推不出关系时返回空串) */ ?>
|
||||||
<span class="rel-label"><?= e($labelToMe) ?></span>
|
<span class="rel-label"><?= e($labelToMe) ?></span>
|
||||||
<?php if ($isMyNick): ?>
|
<?php if ($isMyNick): ?>
|
||||||
· 本名 <?= e((string)$p['name']) ?>
|
· 本名 <?= e((string)$p['name']) ?>
|
||||||
|
|||||||
@@ -28,22 +28,9 @@ $canWrite = \App\Auth::canWrite();
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form class="search" method="get" action="index.php">
|
<?php /* v24(用户 2026-10-02 第 18 条):新建资源页去掉「搜索资源」。
|
||||||
<input type="hidden" name="r" value="resources">
|
资源本来就不多,列表默认折叠、一眼能扫完;搜索框反而把「新建」按钮挤下去了。
|
||||||
<input class="input" type="text" name="keyword" value="<?= e($keyword) ?>" placeholder="搜标题或正文">
|
真的找不着再靠浏览器的 Ctrl+F,不为一个短列表专门养一套搜索。 */ ?>
|
||||||
<select class="select" name="owner">
|
|
||||||
<option value="0">所有人</option>
|
|
||||||
<?php foreach ($people as $p): ?>
|
|
||||||
<option value="<?= (int)$p['id'] ?>"<?= (int)$ownerId === (int)$p['id'] ? ' selected' : '' ?>>
|
|
||||||
<?= e(person_name($p)) ?>
|
|
||||||
</option>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</select>
|
|
||||||
<button class="btn" type="submit">搜索</button>
|
|
||||||
<?php if ($keyword !== '' || $ownerId > 0): ?>
|
|
||||||
<a class="search-clear" href="<?= e(url('resources')) ?>">清除</a>
|
|
||||||
<?php endif; ?>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<?php if ($canWrite): ?>
|
<?php if ($canWrite): ?>
|
||||||
<div class="card card-pad" id="resAddCard" <?= $isEditing ? '' : 'hidden' ?>>
|
<div class="card card-pad" id="resAddCard" <?= $isEditing ? '' : 'hidden' ?>>
|
||||||
@@ -56,15 +43,18 @@ $canWrite = \App\Auth::canWrite();
|
|||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="rs-owner">归属于谁 *</label>
|
<label>归属于谁 *</label>
|
||||||
<select class="select" id="rs-owner" name="owner_id" required>
|
<?php /* v24 第 11 条:与全局选人组件统一(原来是下拉,名册一长就难找) */ ?>
|
||||||
<?php foreach ($people as $p): ?>
|
<?php
|
||||||
<option value="<?= (int)$p['id'] ?>"
|
$ppName = 'owner_id';
|
||||||
<?= (int)($editing['owner_id'] ?? (int)\App\Auth::personId()) === (int)$p['id'] ? ' selected' : '' ?>>
|
$ppPeople = $people;
|
||||||
<?= e(person_name($p)) ?>
|
$ppSelected = [(int)($editing['owner_id'] ?? (int)\App\Auth::personId())];
|
||||||
</option>
|
$ppMode = 'single';
|
||||||
<?php endforeach; ?>
|
$ppOpenBtn = '选择归属人';
|
||||||
</select>
|
$ppTitle = '资源归属于谁';
|
||||||
|
$ppEmptyText = '未选择';
|
||||||
|
require __DIR__ . '/../_person_picker.php';
|
||||||
|
?>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="rs-title">标题 *</label>
|
<label for="rs-title">标题 *</label>
|
||||||
@@ -95,8 +85,7 @@ $canWrite = \App\Auth::canWrite();
|
|||||||
<h2 class="cinema-h2">资源列表</h2>
|
<h2 class="cinema-h2">资源列表</h2>
|
||||||
<?php if (!$rows): ?>
|
<?php if (!$rows): ?>
|
||||||
<p class="empty-hint">
|
<p class="empty-hint">
|
||||||
<?= ($keyword !== '' || $ownerId > 0) ? '没有匹配的资源,换个条件试试。'
|
还没有资源。证件、照片、账号这些"挂在某人名下的东西"都可以记在这里。
|
||||||
: '还没有资源。证件、照片、账号这些"挂在某人名下的东西"都可以记在这里。' ?>
|
|
||||||
</p>
|
</p>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<div class="res-list">
|
<div class="res-list">
|
||||||
@@ -122,31 +111,14 @@ $canWrite = \App\Auth::canWrite();
|
|||||||
<p class="empty-hint">(没有正文)</p>
|
<p class="empty-hint">(没有正文)</p>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
<?php if ($as): ?>
|
<?php
|
||||||
<div class="attach-row">
|
$attRows = $as;
|
||||||
<?php foreach ($as as $a): ?>
|
$attCanEdit = $canWrite && \App\Resource::canWrite($r, (int)\App\Auth::personId());
|
||||||
<span class="attach-item">
|
$attDelUrl = 'resources/attach_del';
|
||||||
<?php if (\App\Attachment::previewable($a)): ?>
|
$attDelField = 'id';
|
||||||
<a href="<?= e(media_url((string)$a['key'])) ?>" target="_blank" rel="noopener">
|
$attExtra = [];
|
||||||
<?= (int)$a['is_image'] === 1 ? '🖼' : '📄' ?> <?= e((string)($a['name'] ?: '附件')) ?>
|
require __DIR__ . '/../_attachments.php';
|
||||||
</a>
|
?>
|
||||||
<?php else: ?>
|
|
||||||
<?= (int)$a['is_image'] === 1 ? '🖼' : '📄' ?> <?= e((string)($a['name'] ?: '附件')) ?>
|
|
||||||
<?php endif; ?>
|
|
||||||
<span class="res-size"><?= e(\App\Attachment::sizeWord($a['size'] ?? 0)) ?></span>
|
|
||||||
<a class="link-quiet" href="<?= e(media_url((string)$a['key'], ['dl' => 1])) ?>">下载</a>
|
|
||||||
<?php if ($canWrite && \App\Resource::canWrite($r, (int)\App\Auth::personId())): ?>
|
|
||||||
<form method="post" action="<?= e(url('resources/attach_del')) ?>" class="res-inline">
|
|
||||||
<?= csrf_field() ?>
|
|
||||||
<input type="hidden" name="id" value="<?= (int)$a['id'] ?>">
|
|
||||||
<button class="btn btn-ghost btn-sm" type="submit"
|
|
||||||
onclick="return confirm('删掉这个附件?')">删除</button>
|
|
||||||
</form>
|
|
||||||
<?php endif; ?>
|
|
||||||
</span>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
<?php if ($sh): ?>
|
<?php if ($sh): ?>
|
||||||
<div class="res-shares">
|
<div class="res-shares">
|
||||||
@@ -175,12 +147,17 @@ $canWrite = \App\Auth::canWrite();
|
|||||||
<form class="inline-form" method="post" action="<?= e(url('resources/share')) ?>">
|
<form class="inline-form" method="post" action="<?= e(url('resources/share')) ?>">
|
||||||
<?= csrf_field() ?>
|
<?= csrf_field() ?>
|
||||||
<input type="hidden" name="id" value="<?= $rid ?>">
|
<input type="hidden" name="id" value="<?= $rid ?>">
|
||||||
<select class="select" name="person_id">
|
<?php
|
||||||
<?php foreach ($people as $p): ?>
|
$ppName = 'person_id';
|
||||||
<?php if ((int)$p['id'] === (int)$r['owner_id']) { continue; } ?>
|
$ppPeople = array_values(array_filter($people,
|
||||||
<option value="<?= (int)$p['id'] ?>"><?= e(person_name($p)) ?></option>
|
fn($p) => (int)$p['id'] !== (int)$r['owner_id']));
|
||||||
<?php endforeach; ?>
|
$ppSelected = [];
|
||||||
</select>
|
$ppMode = 'single';
|
||||||
|
$ppOpenBtn = '选择要共享给谁';
|
||||||
|
$ppTitle = '共享给谁';
|
||||||
|
$ppEmptyText = '未选择';
|
||||||
|
require __DIR__ . '/../_person_picker.php';
|
||||||
|
?>
|
||||||
<select class="select" name="perm">
|
<select class="select" name="perm">
|
||||||
<option value="read">只读</option>
|
<option value="read">只读</option>
|
||||||
<option value="write">可读写</option>
|
<option value="write">可读写</option>
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* 任务模块的「设置」下拉(v24 用户第 10 条)
|
||||||
|
*
|
||||||
|
* 用户原话:「和观影一样,新建任务旁边,也是设置,对任务的设置。」
|
||||||
|
* 所以这里与观影的 _settings_menu.php 用**同一套外观**:只留图标(三条横线)、
|
||||||
|
* 排在最右侧(靠 CSS 的 margin-left:auto)、条目文字前都有图标。
|
||||||
|
*
|
||||||
|
* 用法(require 前 set 好):
|
||||||
|
* $setCurrent 当前页 key('board'|'settings'|'done'),用来高亮
|
||||||
|
*/
|
||||||
|
$setCurrent = (string)($setCurrent ?? '');
|
||||||
|
$setOn = static function (string $key) use ($setCurrent): string {
|
||||||
|
return $setCurrent === $key ? ' class="on"' : '';
|
||||||
|
};
|
||||||
|
$setIcon = static function (string $name): string {
|
||||||
|
$paths = [
|
||||||
|
// 看板设置:分栏
|
||||||
|
'board' => '<rect x="2" y="3" width="4" height="10" rx="1"/><rect x="7.5" y="3" width="4" height="6" rx="1"/>',
|
||||||
|
// 已完成:对勾
|
||||||
|
'done' => '<path d="M3 8.5l3 3 7-7"/>',
|
||||||
|
];
|
||||||
|
$d = $paths[$name] ?? '';
|
||||||
|
if ($d === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return '<svg class="mi" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"'
|
||||||
|
. ' fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"'
|
||||||
|
. ' stroke-linejoin="round">' . $d . '</svg>';
|
||||||
|
};
|
||||||
|
?>
|
||||||
|
<details class="movie-set menu">
|
||||||
|
<summary class="btn btn-icon" title="任务设置" aria-label="任务设置"><svg viewBox="0 0 16 16" width="18" height="18" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"><path d="M2.5 4.5h11M2.5 8h11M2.5 11.5h11"/></svg></summary>
|
||||||
|
<div class="menu-list">
|
||||||
|
<a href="<?= e(url('tasks/settings')) ?>"<?= $setOn('settings') ?>><?= $setIcon('board') ?><span>分类与看板</span></a>
|
||||||
|
<a href="<?= e(url('tasks/done')) ?>"<?= $setOn('done') ?>><?= $setIcon('done') ?><span>已完成</span></a>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
<?php
|
||||||
|
unset($setCurrent, $setOn, $setIcon);
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* 已完成的任务(v24 用户第 10 条)
|
||||||
|
*
|
||||||
|
* 用户原话:「还可以在这里查看已完成的任务,已完成的任务,
|
||||||
|
* 按照分类来进行展示和搜索就可以了。」
|
||||||
|
*
|
||||||
|
* 所以这一页是**只看已完成**的归档:按分类筛 + 按标题搜,不再分列、不拖拽。
|
||||||
|
*
|
||||||
|
* @param array $rows 已完成的任务
|
||||||
|
* @param array $categories 分类清单(做筛选条)
|
||||||
|
* @param int $cat 当前选中的分类(0=全部)
|
||||||
|
* @param string $kw 搜索词
|
||||||
|
*/
|
||||||
|
?>
|
||||||
|
<div class="page-head">
|
||||||
|
<h1>已完成</h1>
|
||||||
|
<span class="sub">共 <?= count($rows) ?> 项</span>
|
||||||
|
<div class="actions">
|
||||||
|
<a class="btn btn-ghost" href="<?= e(url('tasks')) ?>">返回看板</a>
|
||||||
|
<a class="btn btn-ghost" href="<?= e(url('tasks/settings')) ?>">任务设置</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form class="card card-pad" method="get" action="index.php">
|
||||||
|
<input type="hidden" name="r" value="tasks/done">
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="field">
|
||||||
|
<label for="td-cat">分类</label>
|
||||||
|
<select class="select" id="td-cat" name="cat">
|
||||||
|
<option value="0">全部分类</option>
|
||||||
|
<?php foreach ($categories as $c): ?>
|
||||||
|
<option value="<?= (int)$c['id'] ?>" <?= $cat === (int)$c['id'] ? 'selected' : '' ?>>
|
||||||
|
<?= e((string)$c['name']) ?>
|
||||||
|
</option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="td-kw">搜索标题</label>
|
||||||
|
<input class="input" id="td-kw" name="kw" value="<?= e($kw) ?>" placeholder="输入关键字">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="btn-row">
|
||||||
|
<button class="btn" type="submit">筛选</button>
|
||||||
|
<a class="btn btn-ghost" href="<?= e(url('tasks/done')) ?>">清空</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="card card-pad">
|
||||||
|
<?php if (!$rows): ?>
|
||||||
|
<p class="empty-hint">
|
||||||
|
<?= ($cat > 0 || $kw !== '') ? '没有符合条件的已完成任务。' : '还没有已完成的任务。' ?>
|
||||||
|
</p>
|
||||||
|
<?php else: ?>
|
||||||
|
<ul class="done-list">
|
||||||
|
<?php foreach ($rows as $t): ?>
|
||||||
|
<li class="done-item">
|
||||||
|
<a class="done-title" href="<?= e(url('tasks/show', ['id' => (int)$t['id']])) ?>">
|
||||||
|
<?= e((string)$t['title']) ?>
|
||||||
|
</a>
|
||||||
|
<?php if (($t['category_name'] ?? '') !== ''): ?>
|
||||||
|
<span class="node-badge"><?= e((string)$t['category_name']) ?></span>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if (($t['done_at'] ?? '') !== ''): ?>
|
||||||
|
<span class="small muted">完成于 <?= e(substr((string)$t['done_at'], 0, 10)) ?></span>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if (!empty($t['assignee_name'])): ?>
|
||||||
|
<?= person_chip([
|
||||||
|
'id' => (int)$t['assignee_id'], 'name' => (string)$t['assignee_name'],
|
||||||
|
'my_nickname' => (string)($t['assignee_nick'] ?? ''),
|
||||||
|
'avatar' => $t['avatar'] ?? null, 'avatar_color' => $t['avatar_color'] ?? null,
|
||||||
|
]) ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
</li>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</ul>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
@@ -12,20 +12,35 @@
|
|||||||
* @param array $people 名册(选负责人 / 共享给别人)
|
* @param array $people 名册(选负责人 / 共享给别人)
|
||||||
*/
|
*/
|
||||||
$canWrite = \App\Auth::canWrite();
|
$canWrite = \App\Auth::canWrite();
|
||||||
$cols = [['todo', '待办'], ['doing', '进行中'], ['done', '已完成']];
|
// ⚠️ v24 第 10 条:看板上只有「待办 / 进行中」两列 —— 已完成的任务从看板消失,
|
||||||
|
// 要看去「任务设置 → 已完成」按分类看/搜。留着第三列就不是"直接消失"了。
|
||||||
|
$cols = [['todo', '待办'], ['doing', '进行中']];
|
||||||
|
$boards = $boards ?? [];
|
||||||
|
$categories = $categories ?? [];
|
||||||
|
$curBoard = (int)($curBoard ?? 0);
|
||||||
?>
|
?>
|
||||||
<div class="page-head">
|
<div class="page-head">
|
||||||
<h1>任务</h1>
|
<h1>任务</h1>
|
||||||
<span class="sub">共 <?= (int)$total ?> 项</span>
|
<span class="sub">共 <?= (int)$total ?> 项未完成</span>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<?php if ($canWrite): ?>
|
<?php if ($canWrite): ?>
|
||||||
<button class="btn" type="button" id="taskAddBtn">新建任务</button>
|
<button class="btn" type="button" id="taskAddBtn">新建任务</button>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<span class="link-quiet">只读账号</span>
|
<span class="link-quiet">只读账号</span>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
<?php $setCurrent = 'board'; require __DIR__ . '/_settings_menu.php'; ?>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<?php if (count($boards) > 1): ?>
|
||||||
|
<div class="seg kb-boards">
|
||||||
|
<?php foreach ($boards as $b): ?>
|
||||||
|
<a class="seg-btn<?= (int)$b['id'] === $curBoard ? ' is-on' : '' ?>"
|
||||||
|
href="<?= e(url('tasks', ['board' => (int)$b['id']])) ?>"><?= e((string)$b['name']) ?></a>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
<?php if ($canWrite): ?>
|
<?php if ($canWrite): ?>
|
||||||
<div class="card card-pad" id="taskAddCard" hidden>
|
<div class="card card-pad" id="taskAddCard" hidden>
|
||||||
<h2 class="cinema-h2">新建任务</h2>
|
<h2 class="cinema-h2">新建任务</h2>
|
||||||
@@ -36,9 +51,14 @@ $cols = [['todo', '待办'], ['doing', '进行中'], ['done', '已完成']];
|
|||||||
<label for="tk-title">要做什么 *</label>
|
<label for="tk-title">要做什么 *</label>
|
||||||
<input class="input" id="tk-title" name="title" required placeholder="如:周末大扫除">
|
<input class="input" id="tk-title" name="title" required placeholder="如:周末大扫除">
|
||||||
</div>
|
</div>
|
||||||
|
<?php /* v24 第 5 条:截止时刻也走全局统一的「日期 + 时分秒」控件。
|
||||||
|
秒这一档保留(与其它模块同一口径),默认 23:59:59 —— "这天之前做完"
|
||||||
|
比"这天 00:00 之前"更符合直觉。 */ ?>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="tk-due">截止时间(有才会出现在日历里)</label>
|
<label for="tk-due">截止时间(有才会出现在日历里)</label>
|
||||||
<input class="input" type="date" id="tk-due" name="due_date">
|
<?php $dtName = 'due_at'; $dtId = 'tk-due'; $dtValue = null;
|
||||||
|
$dtRequired = false; $dtSeconds = true;
|
||||||
|
require __DIR__ . '/../_datetime.php'; ?>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="tk-assign">负责人</label>
|
<label for="tk-assign">负责人</label>
|
||||||
@@ -57,6 +77,25 @@ $cols = [['todo', '待办'], ['doing', '进行中'], ['done', '已完成']];
|
|||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="tk-cat">分类</label>
|
||||||
|
<select class="select" id="tk-cat" name="category_id">
|
||||||
|
<option value="0">未分类</option>
|
||||||
|
<?php foreach ($categories as $c): ?>
|
||||||
|
<option value="<?= (int)$c['id'] ?>"><?= e((string)$c['name']) ?></option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="tk-board">看板</label>
|
||||||
|
<select class="select" id="tk-board" name="board_id">
|
||||||
|
<?php foreach ($boards as $b): ?>
|
||||||
|
<option value="<?= (int)$b['id'] ?>" <?= (int)$b['id'] === $curBoard ? 'selected' : '' ?>>
|
||||||
|
<?= e((string)$b['name']) ?>
|
||||||
|
</option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="tk-share">共享给(可多选,任何人完成都算完成)</label>
|
<label for="tk-share">共享给(可多选,任何人完成都算完成)</label>
|
||||||
<select class="select" id="tk-share" name="shares[]" multiple size="3">
|
<select class="select" id="tk-share" name="shares[]" multiple size="3">
|
||||||
@@ -123,6 +162,9 @@ $cols = [['todo', '待办'], ['doing', '进行中'], ['done', '已完成']];
|
|||||||
<?php if ((int)($t['attach_count'] ?? 0) > 0): ?>
|
<?php if ((int)($t['attach_count'] ?? 0) > 0): ?>
|
||||||
<span class="kb-ico" title="附件">📎 <?= (int)$t['attach_count'] ?></span>
|
<span class="kb-ico" title="附件">📎 <?= (int)$t['attach_count'] ?></span>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
<?php if (($t['category_name'] ?? '') !== ''): ?>
|
||||||
|
<span class="node-badge" title="分类"><?= e((string)$t['category_name']) ?></span>
|
||||||
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
<?php if ($total > 0): ?>
|
<?php if ($total > 0): ?>
|
||||||
<div class="kb-sub">
|
<div class="kb-sub">
|
||||||
@@ -194,21 +236,36 @@ $cols = [['todo', '待办'], ['doing', '进行中'], ['done', '已完成']];
|
|||||||
});
|
});
|
||||||
|
|
||||||
/* ---- 拖动(渐进增强:拖不动还有上面的左右按钮)---- */
|
/* ---- 拖动(渐进增强:拖不动还有上面的左右按钮)---- */
|
||||||
|
/* ⚠️ v24(用户 2026-10-02 第 8 条):拖完一张卡,松手那一下浏览器还会
|
||||||
|
补一个 click 给卡片里的标题链接 —— 于是"刚拖到位就立刻跳进详情页",
|
||||||
|
看板直接没法用了。
|
||||||
|
修法:拖结束就立个牌子 dragEnded,标题链接的 click 看到牌子就吞掉。
|
||||||
|
牌子在**下一次 mousedown** 时摘掉(真想点进去总得再按一次鼠标),
|
||||||
|
所以不会误伤正常点击 —— 比用 setTimeout 猜时间准得多。 */
|
||||||
var dragging = null;
|
var dragging = null;
|
||||||
|
var dragEnded = false;
|
||||||
|
document.addEventListener('mousedown', function () { dragEnded = false; }, true);
|
||||||
Array.prototype.forEach.call(document.querySelectorAll('.kb-card[draggable="true"]'), function (card) {
|
Array.prototype.forEach.call(document.querySelectorAll('.kb-card[draggable="true"]'), function (card) {
|
||||||
card.addEventListener('dragstart', function (ev) {
|
card.addEventListener('dragstart', function (ev) {
|
||||||
dragging = card;
|
dragging = card;
|
||||||
|
dragEnded = false;
|
||||||
card.classList.add('is-dragging');
|
card.classList.add('is-dragging');
|
||||||
if (ev.dataTransfer) { ev.dataTransfer.effectAllowed = 'move'; }
|
if (ev.dataTransfer) { ev.dataTransfer.effectAllowed = 'move'; }
|
||||||
});
|
});
|
||||||
card.addEventListener('dragend', function () {
|
card.addEventListener('dragend', function () {
|
||||||
card.classList.remove('is-dragging');
|
card.classList.remove('is-dragging');
|
||||||
dragging = null;
|
dragging = null;
|
||||||
|
dragEnded = true;
|
||||||
Array.prototype.forEach.call(document.querySelectorAll('.kb-body.is-over'), function (el) {
|
Array.prototype.forEach.call(document.querySelectorAll('.kb-body.is-over'), function (el) {
|
||||||
el.classList.remove('is-over');
|
el.classList.remove('is-over');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
Array.prototype.forEach.call(document.querySelectorAll('.kb-card .kb-title'), function (a) {
|
||||||
|
a.addEventListener('click', function (ev) {
|
||||||
|
if (dragEnded) { ev.preventDefault(); ev.stopPropagation(); }
|
||||||
|
});
|
||||||
|
});
|
||||||
Array.prototype.forEach.call(document.querySelectorAll('.kb-body'), function (body) {
|
Array.prototype.forEach.call(document.querySelectorAll('.kb-body'), function (body) {
|
||||||
body.addEventListener('dragover', function (ev) {
|
body.addEventListener('dragover', function (ev) {
|
||||||
if (!dragging) return;
|
if (!dragging) return;
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* 任务设置(v24 用户第 9、10 条)
|
||||||
|
*
|
||||||
|
* 用户原话:「和观影一样,新建任务旁边,也是设置,对任务的设置。
|
||||||
|
* 用户可以设置任务分类、看板。还可以在这里查看已完成的任务,
|
||||||
|
* 已完成的任务,按照分类来进行展示和搜索就可以了。」
|
||||||
|
*
|
||||||
|
* 所以这一页两块:看板 / 分类,各有增删改;旁边给一个进「已完成」的入口。
|
||||||
|
*
|
||||||
|
* @param array $boards 看板(带 open_count:里面还有几件没做完)
|
||||||
|
* @param array $categories 分类(带 used_count:被几条任务用过)
|
||||||
|
*/
|
||||||
|
$canWrite = \App\Auth::canWrite();
|
||||||
|
?>
|
||||||
|
<div class="page-head">
|
||||||
|
<h1>任务设置</h1>
|
||||||
|
<span class="sub">看板与分类都由你自己定</span>
|
||||||
|
<div class="actions">
|
||||||
|
<a class="btn btn-ghost" href="<?= e(url('tasks')) ?>">返回任务</a>
|
||||||
|
<a class="btn" href="<?= e(url('tasks/done')) ?>">已完成的任务</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if (!$canWrite): ?>
|
||||||
|
<div class="card card-pad"><p class="empty-hint">只读账号不能改设置。</p></div>
|
||||||
|
<?php else: ?>
|
||||||
|
|
||||||
|
<div class="card card-pad">
|
||||||
|
<h2 class="cinema-h2">看板</h2>
|
||||||
|
<p class="small muted" style="margin-top:0">
|
||||||
|
看板是「分开看几摊事」用的(家务 / 装修 / 旅行准备 …)。任务看板顶上可以切换。
|
||||||
|
删掉一个看板时,里面的任务不会被删,会挪到默认看板。
|
||||||
|
</p>
|
||||||
|
<div class="set-list">
|
||||||
|
<?php foreach ($boards as $b): ?>
|
||||||
|
<form class="inline-form set-row" method="post" action="<?= e(url('tasks/board_update')) ?>">
|
||||||
|
<?= csrf_field() ?>
|
||||||
|
<input type="hidden" name="id" value="<?= (int)$b['id'] ?>">
|
||||||
|
<input class="input" name="name" value="<?= e((string)$b['name']) ?>" required>
|
||||||
|
<span class="small muted"><?= (int)($b['open_count'] ?? 0) ?> 件未完成</span>
|
||||||
|
<button class="btn btn-sm" type="submit">改名</button>
|
||||||
|
</form>
|
||||||
|
<?php if (count($boards) > 1): ?>
|
||||||
|
<form class="inline-form" method="post" action="<?= e(url('tasks/board_del')) ?>">
|
||||||
|
<?= csrf_field() ?>
|
||||||
|
<input type="hidden" name="id" value="<?= (int)$b['id'] ?>">
|
||||||
|
<button class="btn btn-ghost btn-sm" type="submit"
|
||||||
|
onclick="return confirm('删掉这个看板?里面的任务会挪到默认看板,不会丢。')">删除</button>
|
||||||
|
</form>
|
||||||
|
<?php else: ?>
|
||||||
|
<span class="small muted set-tail">最后一个看板不能删</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<form class="inline-form" method="post" action="<?= e(url('tasks/board_add')) ?>" style="margin-top:10px">
|
||||||
|
<?= csrf_field() ?>
|
||||||
|
<input class="input" name="name" placeholder="新看板名,如:装修" required>
|
||||||
|
<button class="btn btn-sm" type="submit">添加看板</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card card-pad">
|
||||||
|
<h2 class="cinema-h2">分类</h2>
|
||||||
|
<p class="small muted" style="margin-top:0">
|
||||||
|
分类是给任务贴的标签(证件 / 报销 / 维修 …),用来在「已完成」里归类查看与搜索。
|
||||||
|
</p>
|
||||||
|
<div class="set-list">
|
||||||
|
<?php if (!$categories): ?>
|
||||||
|
<p class="empty-hint">还没有分类。</p>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php foreach ($categories as $c): ?>
|
||||||
|
<form class="inline-form set-row" method="post" action="<?= e(url('tasks/cat_update')) ?>">
|
||||||
|
<?= csrf_field() ?>
|
||||||
|
<input type="hidden" name="id" value="<?= (int)$c['id'] ?>">
|
||||||
|
<input class="input" name="name" value="<?= e((string)$c['name']) ?>" required>
|
||||||
|
<span class="small muted"><?= (int)($c['used_count'] ?? 0) ?> 条用过</span>
|
||||||
|
<button class="btn btn-sm" type="submit">改名</button>
|
||||||
|
</form>
|
||||||
|
<form class="inline-form" method="post" action="<?= e(url('tasks/cat_del')) ?>">
|
||||||
|
<?= csrf_field() ?>
|
||||||
|
<input type="hidden" name="id" value="<?= (int)$c['id'] ?>">
|
||||||
|
<button class="btn btn-ghost btn-sm" type="submit"
|
||||||
|
onclick="return confirm('删掉这个分类?用它标过的任务会变成「未分类」。')">删除</button>
|
||||||
|
</form>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<form class="inline-form" method="post" action="<?= e(url('tasks/cat_add')) ?>" style="margin-top:10px">
|
||||||
|
<?= csrf_field() ?>
|
||||||
|
<input class="input" name="name" placeholder="新分类名,如:证件" required>
|
||||||
|
<button class="btn btn-sm" type="submit">添加分类</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php endif; ?>
|
||||||
+46
-27
@@ -88,8 +88,10 @@ $isChild = !empty($t['parent_id']);
|
|||||||
<input class="input" name="title" required value="<?= e((string)$t['title']) ?>">
|
<input class="input" name="title" required value="<?= e((string)$t['title']) ?>">
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>截止日期(留空=不上日历)</label>
|
<label>截止时间(留空=不上日历)</label>
|
||||||
<input class="input" type="date" name="due_date" value="<?= e(substr((string)($t['due_at'] ?? ''), 0, 10)) ?>">
|
<?php $dtName = 'due_at'; $dtValue = $t['due_at'] ?? null;
|
||||||
|
$dtRequired = false; $dtSeconds = true;
|
||||||
|
require __DIR__ . '/../_datetime.php'; ?>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>负责人</label>
|
<label>负责人</label>
|
||||||
@@ -123,6 +125,28 @@ $isChild = !empty($t['parent_id']);
|
|||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<?php /* v24 第 9、10 条:分类与看板(看板把任务放到哪一块,分类是标签) */ ?>
|
||||||
|
<div class="field">
|
||||||
|
<label>分类</label>
|
||||||
|
<select class="select" name="category_id">
|
||||||
|
<option value="0">未分类</option>
|
||||||
|
<?php foreach (($categories ?? []) as $c): ?>
|
||||||
|
<option value="<?= (int)$c['id'] ?>"<?= (int)($t['category_id'] ?? 0) === (int)$c['id'] ? ' selected' : '' ?>>
|
||||||
|
<?= e((string)$c['name']) ?>
|
||||||
|
</option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>看板</label>
|
||||||
|
<select class="select" name="board_id">
|
||||||
|
<?php foreach (($boards ?? []) as $b): ?>
|
||||||
|
<option value="<?= (int)$b['id'] ?>"<?= (int)($t['board_id'] ?? 0) === (int)$b['id'] ? ' selected' : '' ?>>
|
||||||
|
<?= e((string)$b['name']) ?>
|
||||||
|
</option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
<div class="field full">
|
<div class="field full">
|
||||||
<label>备注</label>
|
<label>备注</label>
|
||||||
<textarea class="input" name="note" rows="2"><?= e((string)($t['note'] ?? '')) ?></textarea>
|
<textarea class="input" name="note" rows="2"><?= e((string)($t['note'] ?? '')) ?></textarea>
|
||||||
@@ -175,7 +199,8 @@ $isChild = !empty($t['parent_id']);
|
|||||||
<?= csrf_field() ?>
|
<?= csrf_field() ?>
|
||||||
<input type="hidden" name="parent_id" value="<?= (int)$t['id'] ?>">
|
<input type="hidden" name="parent_id" value="<?= (int)$t['id'] ?>">
|
||||||
<input class="input" name="title" required placeholder="如:擦玻璃">
|
<input class="input" name="title" required placeholder="如:擦玻璃">
|
||||||
<input class="input" type="date" name="due_date">
|
<?php $dtName = 'due_at'; $dtValue = null; $dtRequired = false; $dtSeconds = true;
|
||||||
|
require __DIR__ . '/../_datetime.php'; ?>
|
||||||
<button class="btn btn-sm" type="submit">加上</button>
|
<button class="btn btn-sm" type="submit">加上</button>
|
||||||
</form>
|
</form>
|
||||||
</details>
|
</details>
|
||||||
@@ -189,24 +214,14 @@ $isChild = !empty($t['parent_id']);
|
|||||||
<?php if (!$t['attachs']): ?>
|
<?php if (!$t['attachs']): ?>
|
||||||
<p class="empty-hint">还没有附件。</p>
|
<p class="empty-hint">还没有附件。</p>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<div class="attach-row">
|
<?php
|
||||||
<?php foreach ($t['attachs'] as $a): ?>
|
$attRows = $t['attachs'];
|
||||||
<span class="attach-item">
|
$attCanEdit = $canWrite;
|
||||||
<a href="<?= e(media_url((string)$a['key'])) ?>" target="_blank" rel="noopener">
|
$attDelUrl = 'tasks/attach_del';
|
||||||
<?= (int)$a['is_image'] === 1 ? '🖼' : '📄' ?> <?= e((string)($a['name'] ?: '附件')) ?>
|
$attDelField = 'id';
|
||||||
</a>
|
$attExtra = [];
|
||||||
<span class="res-size"><?= e(\App\Attachment::sizeWord($a['size'] ?? 0)) ?></span>
|
require __DIR__ . '/../_attachments.php';
|
||||||
<a class="link-quiet" href="<?= e(media_url((string)$a['key'], ['dl' => 1])) ?>">下载</a>
|
?>
|
||||||
<?php if ($canWrite): ?>
|
|
||||||
<form method="post" action="<?= e(url('tasks/attach_del')) ?>" class="res-inline">
|
|
||||||
<?= csrf_field() ?>
|
|
||||||
<input type="hidden" name="id" value="<?= (int)$a['id'] ?>">
|
|
||||||
<button class="btn btn-ghost btn-sm" type="submit" onclick="return confirm('删掉这个附件?')">删除</button>
|
|
||||||
</form>
|
|
||||||
<?php endif; ?>
|
|
||||||
</span>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<?php if ($canWrite): ?>
|
<?php if ($canWrite): ?>
|
||||||
<form class="inline-form" method="post" enctype="multipart/form-data" action="<?= e(url('tasks/attach_add')) ?>">
|
<form class="inline-form" method="post" enctype="multipart/form-data" action="<?= e(url('tasks/attach_add')) ?>">
|
||||||
@@ -243,12 +258,16 @@ $isChild = !empty($t['parent_id']);
|
|||||||
<form class="inline-form" method="post" action="<?= e(url('tasks/share')) ?>">
|
<form class="inline-form" method="post" action="<?= e(url('tasks/share')) ?>">
|
||||||
<?= csrf_field() ?>
|
<?= csrf_field() ?>
|
||||||
<input type="hidden" name="id" value="<?= (int)$t['id'] ?>">
|
<input type="hidden" name="id" value="<?= (int)$t['id'] ?>">
|
||||||
<select class="select" name="person_id">
|
<?php
|
||||||
<?php foreach ($people as $p): ?>
|
$ppName = 'person_id';
|
||||||
<?php if ((int)$p['id'] === $meId) { continue; } ?>
|
$ppPeople = array_values(array_filter($people, fn($p) => (int)$p['id'] !== $meId));
|
||||||
<option value="<?= (int)$p['id'] ?>"><?= e(person_name($p)) ?></option>
|
$ppSelected = [];
|
||||||
<?php endforeach; ?>
|
$ppMode = 'single';
|
||||||
</select>
|
$ppOpenBtn = '选择要共享给谁';
|
||||||
|
$ppTitle = '共享给谁';
|
||||||
|
$ppEmptyText = '未选择';
|
||||||
|
require __DIR__ . '/../_person_picker.php';
|
||||||
|
?>
|
||||||
<select class="select" name="perm">
|
<select class="select" name="perm">
|
||||||
<option value="write">可写</option>
|
<option value="write">可写</option>
|
||||||
<option value="read">只读</option>
|
<option value="read">只读</option>
|
||||||
|
|||||||
@@ -57,12 +57,12 @@ $nfPrefix = $nfIsEdit ? 'ne' . (int)$nfNode['id'] : ('n' . ($nfCategory === 'rea
|
|||||||
<div class="field">
|
<div class="field">
|
||||||
<label>开始</label>
|
<label>开始</label>
|
||||||
<?php $dtName = 'starts_at'; $dtValue = $nfNode['starts_at'] ?? '';
|
<?php $dtName = 'starts_at'; $dtValue = $nfNode['starts_at'] ?? '';
|
||||||
$dtId = $nfPrefix . '-s'; $dtSeconds = false; require __DIR__ . '/../_datetime.php'; ?>
|
$dtId = $nfPrefix . '-s'; $dtSeconds = true; require __DIR__ . '/../_datetime.php'; ?>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>结束</label>
|
<label>结束</label>
|
||||||
<?php $dtName = 'ends_at'; $dtValue = $nfNode['ends_at'] ?? '';
|
<?php $dtName = 'ends_at'; $dtValue = $nfNode['ends_at'] ?? '';
|
||||||
$dtId = $nfPrefix . '-e'; $dtSeconds = false; require __DIR__ . '/../_datetime.php'; ?>
|
$dtId = $nfPrefix . '-e'; $dtSeconds = true; require __DIR__ . '/../_datetime.php'; ?>
|
||||||
</div>
|
</div>
|
||||||
<div class="field full">
|
<div class="field full">
|
||||||
<label for="<?= e($nfPrefix) ?>-detail">记录</label>
|
<label for="<?= e($nfPrefix) ?>-detail">记录</label>
|
||||||
|
|||||||
+14
-16
@@ -47,13 +47,13 @@ if ($isEdit) {
|
|||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="t-start">出发</label>
|
<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 = false; require __DIR__ . '/../_datetime.php'; ?>
|
$dtId = 't-start'; $dtSeconds = true; 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>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="t-end">回来</label>
|
<label for="t-end">回来</label>
|
||||||
<?php $dtName = 'ends_at'; $dtValue = $t['ends_at'] ?? ''; $dtId = 't-end'; $dtSeconds = false;
|
<?php $dtName = 'ends_at'; $dtValue = $t['ends_at'] ?? ''; $dtId = 't-end'; $dtSeconds = true;
|
||||||
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; ?>
|
||||||
</div>
|
</div>
|
||||||
@@ -77,23 +77,21 @@ if ($isEdit) {
|
|||||||
|
|
||||||
<div class="field full">
|
<div class="field full">
|
||||||
<label>同行的人</label>
|
<label>同行的人</label>
|
||||||
|
<?php /* v24 第 11 条:与全局选人组件统一(原来是平铺的 checkbox 网格,
|
||||||
|
名册一多就变成"在一长串里瞪眼找",也搜不了拼音) */ ?>
|
||||||
<?php if (!$people): ?>
|
<?php if (!$people): ?>
|
||||||
<p class="empty-hint">名册里还没有人。</p>
|
<p class="empty-hint">名册里还没有人。</p>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<div class="pick-list">
|
<?php
|
||||||
<?php foreach ($people as $p): ?>
|
$ppName = 'attendees[]';
|
||||||
<label class="pick-item">
|
$ppPeople = $people;
|
||||||
<input type="checkbox" name="attendees[]" value="<?= (int)$p['id'] ?>"
|
$ppSelected = $selIds;
|
||||||
<?= in_array((int)$p['id'], $selIds, true) ? 'checked' : '' ?>>
|
$ppMode = 'multi';
|
||||||
<?php if (!empty($p['avatar'])): ?>
|
$ppOpenBtn = '+ 添加同行人';
|
||||||
<img class="pick-ph" src="<?= e(media_url((string)$p['avatar'])) ?>" alt="">
|
$ppTitle = '选择同行的人';
|
||||||
<?php else: ?>
|
$ppTip = '';
|
||||||
<span class="pick-ph"></span>
|
require __DIR__ . '/../_person_picker.php';
|
||||||
<?php endif; ?>
|
?>
|
||||||
<span class="pick-name"><?= e(person_name($p)) ?></span>
|
|
||||||
</label>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -71,7 +71,13 @@ $memberIds = array_map('intval', array_column((array)($t['attendees'] ?? []), 'i
|
|||||||
<div class="photo-grid">
|
<div class="photo-grid">
|
||||||
<?php foreach ($photos as $ph): ?>
|
<?php foreach ($photos as $ph): ?>
|
||||||
<figure class="photo-cell">
|
<figure class="photo-cell">
|
||||||
<a href="<?= e(media_url((string)$ph['key'])) ?>" target="_blank" rel="noopener">
|
<?php /* v24 第 3 条:照片也在**本页**预览(原来 target=_blank 是另开一个标签页
|
||||||
|
只显示一张图,既没有下载按钮也不好回来看) */ ?>
|
||||||
|
<a href="<?= e(media_url((string)$ph['key'])) ?>"
|
||||||
|
data-prev="image"
|
||||||
|
data-src="<?= e(media_url((string)$ph['key'])) ?>"
|
||||||
|
data-dl="<?= e(media_url((string)$ph['key'], ['dl' => 1])) ?>"
|
||||||
|
data-name="<?= e((string)($ph['name'] ?? '照片')) ?>">
|
||||||
<img src="<?= e(media_url((string)$ph['key'])) ?>" alt="<?= e((string)($ph['name'] ?? '')) ?>">
|
<img src="<?= e(media_url((string)$ph['key'])) ?>" alt="<?= e((string)($ph['name'] ?? '')) ?>">
|
||||||
</a>
|
</a>
|
||||||
<?php if ($canWrite): ?>
|
<?php if ($canWrite): ?>
|
||||||
@@ -107,7 +113,8 @@ $memberIds = array_map('intval', array_column((array)($t['attendees'] ?? []), 'i
|
|||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<ol class="node-list">
|
<ol class="node-list">
|
||||||
<?php foreach ($list as $n): ?>
|
<?php foreach ($list as $n): ?>
|
||||||
<li class="node-row status-<?= e((string)$n['status']) ?>">
|
<?php /* v24:日历里点「行程节点」是跳 #node-<id> 过来的,这里给个锚点好定位。 */ ?>
|
||||||
|
<li class="node-row status-<?= e((string)$n['status']) ?>" id="node-<?= (int)$n['id'] ?>">
|
||||||
<span class="node-ico" title="<?= e(\App\Trip::typeWord((string)$n['node_type'])) ?>">
|
<span class="node-ico" title="<?= e(\App\Trip::typeWord((string)$n['node_type'])) ?>">
|
||||||
<?= e(\App\Trip::typeIcon((string)$n['node_type'])) ?>
|
<?= e(\App\Trip::typeIcon((string)$n['node_type'])) ?>
|
||||||
</span>
|
</span>
|
||||||
@@ -132,13 +139,12 @@ $memberIds = array_map('intval', array_column((array)($t['attendees'] ?? []), 'i
|
|||||||
</div>
|
</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<?php if (!empty($n['attachs'])): ?>
|
<?php if (!empty($n['attachs'])): ?>
|
||||||
<div class="attach-row">
|
<?php
|
||||||
<?php foreach ($n['attachs'] as $at): ?>
|
$attRows = $n['attachs'];
|
||||||
<a class="attach-item" href="<?= e(media_url((string)$at['key'], ['dl' => 1])) ?>">
|
$attCanEdit = false; // 节点附件不单独给删除入口(防越权:得先判整趟旅程)
|
||||||
<?= (int)$at['is_image'] === 1 ? '🖼' : '📄' ?> <?= e((string)($at['name'] ?: '附件')) ?>
|
$attDelUrl = '';
|
||||||
</a>
|
require __DIR__ . '/../_attachments.php';
|
||||||
<?php endforeach; ?>
|
?>
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
<?php if ($canWrite): ?>
|
<?php if ($canWrite): ?>
|
||||||
|
|||||||
Reference in New Issue
Block a user