Files
FamilyBook/templates/resources/index.php
T
laoyang 7544fb9f6a v41 第 41 批:流水线提速 + 12 项界面/交互开发 + DAV 彻底修复
DAV(用户「务必彻底解决」):
- 真根因是 home-set 层级错:CalDav::HOME_PATH 原为 /dav/calendars/、
  CardDav 原为 /carddav/addressbooks/,而日历本/通讯录的 href 在
  /dav/N.ics/ 与 /carddav/N.vcf/ —— home-set 下面一本都没有。
  Thunderbird 按 home-set 前缀认成员,于是「找不到日历本 / 找不到联系人」。
  改为 /dav/ 与 /carddav/(所有既有 URL 一字不变,老配置不作废)。
- 又发现并修掉 Depth:0 违反 RFC 4918:原实现无论 depth 都列出全部子集合,
  且第一条 response 是日历本(不含 current-user-principal)。改为按 depth 分流,
  Depth:0 只回资源自身、Depth:1 才列出子集合。

界面与交互:
- 观影:新建影院弹窗层级修正 + 详细地址;照片左右按钮悬停不再位移;
  弹框底栏改「确定 / 取消」。
- 旅程:新建旅程改弹窗并按观影式排版;详情页四个动作按钮按用户指定顺序与配色;
  编辑与新增行程弹窗化(单容器 + AJAX);行程时间控件改 AntD(精确到分);
  参与人默认带上自己;车次号/车牌号紧跟交通方式词。
- 资源:新建/编辑改弹窗 + 观影式排版 + 新建时即定共享人。
- 纪念日:新建改弹框 + 观影式排版。

验证:
- e2e 1510/0;v41 十二项真机 48/0;DAV 反向验证 8/0;
  部署断言空跑 337/0;模板 div 深度三形态穷举 33 组全配平。
- 线上 DAV 终验(--resolve 真实入口 + 真 DAV 口令)12/0;
  线上确认 Depth:0 恰好 1 个 response。
2026-10-11 10:12:11 +08:00

852 lines
42 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* 资源(v12;v24 第 23 批 用户第 1 条重构)。
*
* 用户原话:
* 「资源页面,还可以更简洁,资源打开后,没有正文就不显示(没有正文)这句话了,
* 直接显示附件。附件列完后,列出来人员,所有者、共享给谁了。然后其他的都不要。
* 把编辑、共享、附件、删除,四个按钮,放在每个资源的右上角,和标题一排,
* 点击后分别是编辑信息、设置共享人员,包括删除共享人等,附件,点击后弹框,
* 允许用户拖动上传。编辑黄色、共享绿色、附件蓝色、删除红色。
* 按钮点击后的功能,都是弹框。」
*
* → 这一版做了三件事:
* ① **展开区只留"看"**:正文(没有就**不占位**,不再写「(没有正文)」)、
* 附件墙(只读,可预览/下载)、人员行(所有者 + 共享给谁)。
* 原来堆在展开区底部的补传附件 / 共享设置 / 编辑 / 删除 四坨内联表单**全部撤掉** ——
* 那些是"改"的动作,都搬到弹框里去。
* ② **标题行右上角四色按钮**:编辑(黄) 共享(绿) 附件(蓝) 删除(红),与标题同一排。
* ③ **四个动作全在弹框里**,容器 `#resPanels` 全页只有一份 ——
* 与名册的 `#rosterMenus`、财务的 `#ledPanels` 是同一套做法:
* 行上只留按钮,真正的 `<form>` 只渲染一份。
* ⚠️ 这正是项目一直在治的那类问题:每行内联一套表单,条目一多就是几千个 `<form>`,
* 密码管理器逐个扫描会把主线程占死(用户报过两次,见 ledger/people 的注释)。
*
* ⚠️ 按钮放在 `<summary>` 里为什么不会误开合:
* `<summary>` 内任意位置点击都会 toggle `<details>`(浏览器默认行为),
* 所以四个按钮的 click 处理里**必须** `preventDefault()`(见文件末尾脚本的说明)。
* 真机探针专门断言了「点按钮 → 弹框打开、且这一条**没有**被展开」。
*
* ⚠️ 结构性约束:`<summary>` 里只能放 **phrasing content** ——
* 所以按钮组写成 span(class 为 res-ops)而不是 div;
* `<form>` 更是绝对不能放进去(那是 flow content)。
* 这也是"四个动作必须进弹框"的一条额外理由:删除/补传/共享/编辑在原实现里
* 全都是 `<form>`,留在标题行会直接产出非法 HTML。
*
* @param array $rows 我能看到的资源(带 perm / owner_nick)
* @param array $people 名册(新建/编辑时选归属人、共享给别人)
* @param array $attachs resource_id => 附件列表
* @param array $shares resource_id => 共享列表
*/
$isEditing = !empty($editing);
$canWrite = \App\Auth::canWrite();
$myPerson = (int)\App\Auth::personId();
?>
<div class="page-head">
<h1>资源</h1>
<span class="sub">共 <?= count($rows) ?> 条</span>
<div class="actions">
<?php if ($canWrite): ?>
<button class="btn" type="button" id="resAddBtn"><?= $isEditing ? '收起' : '新建资源' ?></button>
<?php else: ?>
<span class="link-quiet">只读账号</span>
<?php endif; ?>
</div>
</div>
<?php /* v24(用户 2026-10-02 第 18 条):**新建资源页**去掉「搜索资源」——
资源本来就不多,搜索框反而把「新建」按钮挤下去了。
⚠️ 但 2026-10-03 第 6 / 10 条把搜索**请回列表页**了,而且是"上来就是搜索":
资源会越攒越多(第 10 条原话:「可能会非常多」),没有搜索就只能滚动。
两件事不矛盾:新建那块仍然干净,搜索长在**列表**上。
区别在于现在是页面顶部的**第一块**,不是夹在新建表单里的一个输入框。 */ ?>
<div class="card card-pad res-search-card">
<form class="search res-search" method="get" action="index.php">
<input type="hidden" name="r" value="resources">
<input type="hidden" name="sort" value="<?= e($sort) ?>">
<label class="sr-only" for="resKw">搜索资源</label>
<input class="input" id="resKw" name="keyword" value="<?= e($keyword) ?>"
placeholder="搜标题 / 正文,如「身份证」" autocomplete="off">
<button class="btn btn-ghost btn-sm" type="submit">搜索</button>
<?php if ($keyword !== ''): ?>
<a class="btn btn-ghost btn-sm" href="<?= e(url('resources')) ?>">清除</a>
<?php endif; ?>
</form>
<?php /* 排序:最新新增 / 最近改动。用链接而不是下拉,一眼看出当前按什么排的。 */ ?>
<div class="res-sortbar">
<span class="small muted">排序</span>
<span class="seg">
<a class="seg-btn<?= $sort === 'used' ? ' is-on' : '' ?>"
href="<?= e(url('resources', ['sort' => 'used', 'keyword' => $keyword])) ?>">最近改动</a>
<a class="seg-btn<?= $sort === 'new' ? ' is-on' : '' ?>"
href="<?= e(url('resources', ['sort' => 'new', 'keyword' => $keyword])) ?>">最新新增</a>
</span>
<?php if (!$showAll && $totalAll > count($rows)): ?>
<a class="link-quiet res-showall" href="<?= e(url('resources', ['sort' => $sort, 'keyword' => $keyword, 'all' => 1])) ?>">
显示全部 <?= (int)$totalAll ?> 条
</a>
<?php endif; ?>
</div>
</div>
<?php if ($canWrite): ?>
<?php /* 🔴 v41(用户 2026-10-10 第 9 条):「添加资源,采用弹窗形式,并在新建资源的
时候,就确定共享人。排版和观影一样,前面是标题,后面跟着冒号,然后是各类输入框。
各类框,放在一个页面,包括共享人和附件,编辑也是一样,直接复用添加这个输入框即可。」
⇒ 三件事:
① 从"页面里可开合的一块"改成**弹框**(`#resAddCard` → `.cal-modal`);
② 排版改观影式(`.mv-row`:左标签固定 62px + 右对齐 + 自动冒号);
③ **共享人**与**附件**都放进这一张表单(原来共享只在那条的「共享」弹框里做)。
⚠️ `$isEditing` 时弹框**自动打开**(从列表点"编辑"进来就是要改它)。 */ ?>
<div class="cal-modal" id="resAddCard" role="dialog" aria-modal="true"
aria-label="<?= $isEditing ? '编辑资源' : '新建资源' ?>"
<?= ($isEditing || !empty($resOpen)) ? '' : 'hidden' ?>>
<div class="cal-modal-panel mv-panel">
<div class="cal-modal-head">
<b id="resAddTitle"><?= $isEditing ? '编辑资源' : '新建资源' ?></b>
<button type="button" class="cal-modal-x" id="resAddX" aria-label="关闭">&#10005;</button>
</div>
<form method="post" enctype="multipart/form-data" id="resForm"
action="<?= e(url($isEditing ? 'resources/update' : 'resources/create')) ?>">
<?= csrf_field() ?>
<?php if ($isEditing): ?>
<input type="hidden" name="id" value="<?= (int)$editing['id'] ?>">
<?php endif; ?>
<div class="mv-form-inner">
<div class="mv-grid">
<div class="mv-row">
<span class="mv-lb">归属于谁<i>*</i></span>
<div class="mv-fill">
<?php
$ppName = 'owner_id';
$ppPeople = $people;
$ppSelected = [(int)($editing['owner_id'] ?? (int)\App\Auth::personId())];
$ppMode = 'single';
$ppOpenBtn = '选择归属人';
$ppTitle = '资源归属于谁';
$ppEmptyText = '未选择';
require __DIR__ . '/../_person_picker.php';
?>
</div>
</div>
<div class="mv-row">
<span class="mv-lb">标题<i>*</i></span>
<input class="input" id="rs-title" name="title" required
value="<?= e($editing['title'] ?? '') ?>" placeholder="如:身份证 / 学位证">
</div>
<div class="mv-row">
<span class="mv-lb">正文</span>
<textarea class="input" id="rs-content" name="content" rows="3"
placeholder="编号、有效期、放在哪里…"><?= e($editing['content'] ?? '') ?></textarea>
</div>
<?php /* 🔴 v41:**共享给谁**(用户第 9 条"新建的时候就确定共享人")。
⚠️ 这里只给"只读" —— 新建时最多到只读;要给人可写,
去列表里那条资源的「共享」弹框单独设(那里能选权限)。
⚠️ 归属人自己会被服务端自动跳过(不会产生多余共享记录)。 */ ?>
<div class="mv-row">
<span class="mv-lb">共享</span>
<div class="mv-fill">
<?php
$ppName = 'share_ids[]';
$ppPeople = $people;
$ppSelected = array_map('intval', array_column(
$isEditing ? \App\Resource::shares((int)$editing['id']) : [], 'person_id'));
$ppMode = 'multi';
$ppOpenBtn = '+ 共享给谁';
$ppTitle = '这条资源共享给谁(只读)';
$ppTip = '归属人不用勾(本来就是他的)。要给人「可读写」,到列表里那条的「共享」里设。';
require __DIR__ . '/../_person_picker.php';
?>
</div>
</div>
<div class="mv-row mv-row-media">
<span class="mv-lb">附件</span>
<div class="mv-media-body">
<?php /* ⚠️ 新建时资源还不存在,附件只能在这里一起提交(建完再挂)。
列表里那条的附件管理则走右上角蓝色的「附件」弹框。
两处的文件框 id 必须**不同**(同 id 会撞),所以这里显式给一个。 */
$sdName = 'attach[]'; $sdSlot = 'photo'; $sdId = 'sd-attach';
require __DIR__ . '/../_stage_drop.php'; ?>
</div>
</div>
</div>
</div>
<div class="cal-modal-foot">
<span class="cal-modal-tip" id="resAddTip"></span>
<button class="btn btn-sm" type="submit" form="resForm" id="resAddSubmit">确定</button>
<button class="btn btn-ghost btn-sm" type="button" id="resAddCancel">取消</button>
</div>
</div><?php /* /.cal-modal-panel */ ?>
</form>
</div>
<?php endif; ?>
<?php /* 🔴 v41:列表内容包一层 `#resListHost` —— 弹框保存成功后前端只换这一块。 */ ?>
<div id="resListHost">
<div class="card card-pad">
<h2 class="cinema-h2">
资源列表
<?php
// ⚠️ 提示语要说清"为什么只有这些":
// · 搜索时只有几条是**因为搜索**(不是被截断)→ 别在这儿提"只显示最新 10 条",
// 否则用户会以为"搜出来的结果又被砍了";
// · 没搜索又确实多于 10 条,才提示截断,并给出"显示全部"。
?>
<?php if ($keyword !== ''): ?>
<span class="hint">匹配「<?= e($keyword) ?>」的 <?= count($rows) ?> 条</span>
<?php elseif (!$showAll && $totalAll > count($rows)): ?>
<span class="hint">只显示最新的 <?= (int)$limit ?> 条(共 <?= (int)$totalAll ?> 条)</span>
<?php else: ?>
<span class="hint">共 <?= count($rows) ?> 条</span>
<?php endif; ?>
</h2>
<?php if (!$rows): ?>
<p class="empty-hint">
<?= $keyword !== ''
? '没有匹配「' . e($keyword) . '」的资源,换个词试试。'
: '还没有资源。证件、照片、账号这些"挂在某人名下的东西"都可以记在这里。' ?>
</p>
<?php else: ?>
<div class="res-list">
<?php foreach ($rows as $r): ?>
<?php
$rid = (int)$r['id'];
$as = $attachs[$rid] ?? [];
$sh = $shares[$rid] ?? [];
$canEdit = $canWrite && \App\Resource::canWrite($r, $myPerson);
$ownerArr = [
'id' => (int)$r['owner_id'], 'name' => (string)$r['owner_name'],
'my_nickname' => (string)($r['owner_nick'] ?? ''),
'avatar' => $r['avatar'] ?? null, 'avatar_color' => $r['avatar_color'] ?? null,
];
?>
<details class="res-item">
<summary class="res-head">
<span class="res-title"><?= e((string)$r['title']) ?></span>
<?php /* 归属人标签:查询里"人"的字段没加前缀(owner_name / avatar / avatar_color),
只有昵称带 owner_ 前缀 —— 在这里拼成 person_chip 认的形状。 */ ?>
<?= person_chip($ownerArr) ?>
<span class="node-badge"><?= e(\App\Resource::permWord((string)$r['perm'])) ?></span>
<?php if ($as): ?><span class="res-n"><?= count($as) ?> 个附件</span><?php endif; ?>
<?php /* ---- 右上角按钮(与标题同一排)----
🔴 v28 用户第 7 条:「资源功能,编辑和附件两个按钮合并吧,就叫编辑,
既可以更改信息,也可以删除和增加附件,之前分开,操作的时候,
还是有割裂感。共享也合并,这样就剩下两个按钮,编辑和删除。」
⇒ 从四颗(编辑/共享/附件/删除)收成**两颗**:
· 「编辑」→ 打开一个**带页签**的弹框:信息 / 附件 / 共享;
· 「删除」→ 保持独立(危险动作不该混在编辑里,误点代价不可逆)。
⚠️ 仍然必须用 <span> 包(phrasing content);`<div>`/`<form>` 放
<summary> 里是非法 HTML。
⚠️ 按钮仍带 `data-res-open`,脚本对它 `preventDefault()`
以阻止 <summary> 顺带把这一条展开。
⚠️ 页签要的数据(标题/正文/归属人/附件/已共享)全都挂在这一颗按钮上:
弹框全页只有一份,点哪条就把哪条的数据灌进去 ——
这正是"避免每行一份表单"那条老规矩(2369 个 form 把页面拖死)。 */ ?>
<?php if ($canEdit): ?>
<span class="res-ops">
<button type="button" class="btn btn-warn btn-sm res-op"
data-res-open="edit" data-res-id="<?= $rid ?>"
data-res-title="<?= e((string)$r['title']) ?>"
data-res-content="<?= e((string)($r['content'] ?? '')) ?>"
data-res-owner="<?= (int)$r['owner_id'] ?>"
data-res-shares="<?= e((string)json_encode(array_map(
static fn($s): array => [
'id' => (int)$s['person_id'],
'name' => (string)$s['name'],
'perm' => (string)($s['perm'] ?? 'read'),
], $sh), JSON_UNESCAPED_UNICODE)) ?>"
data-res-atts="<?= e((string)json_encode(array_map(
static function ($a): array {
$k = \App\Attachment::kind($a);
return [
'id' => (int)$a['id'],
'name' => (string)($a['name'] ?? ''),
'kw' => \App\Attachment::kindWord($k),
'kind' => $k,
'url' => media_url((string)$a['key']),
'dl' => media_url((string)$a['key'], ['dl' => 1]),
'sz' => \App\Attachment::sizeWord($a['size'] ?? 0),
];
}, $as), JSON_UNESCAPED_UNICODE)) ?>">编辑</button>
<button type="button" class="btn btn-del btn-sm res-op"
data-res-open="del" data-res-id="<?= $rid ?>"
data-res-title="<?= e((string)$r['title']) ?>"
data-res-n="<?= count($as) ?>">删除</button>
</span>
<?php endif; ?>
<span class="res-caret" aria-hidden="true">▾</span>
</summary>
<div class="res-body">
<?php /* ① 正文:**没有就不占位**(用户第 1 条:不再写「(没有正文)」)。 */ ?>
<?php if (!empty($r['content'])): ?>
<p class="info-text"><?= nl2br(e((string)$r['content'])) ?></p>
<?php endif; ?>
<?php /* ② 附件墙:这里是"看",删除入口在蓝色「附件」弹框里(见下)。
⚠️ `$attCanEdit = false`:把删除按钮从展开区撤掉。
理由:① 用户要的四个动作都在右上角按钮里;
② 内联的删除表单是"每行一个 <form>"膨胀的最后一处。 */ ?>
<?php
$attRows = $as;
$attCanEdit = false;
$attDelUrl = 'resources/attach_del';
$attDelField = 'id';
$attExtra = [];
require __DIR__ . '/../_attachments.php';
?>
<?php /* ③ 人员行:附件列完之后列出来 —— 所有者、共享给谁(用户原话)。 */ ?>
<div class="res-people">
<span class="res-label">所有者</span>
<?= person_chip($ownerArr) ?>
<?php if ($sh): ?>
<span class="res-label">共享给</span>
<?php foreach ($sh as $s): ?>
<?= person_chip($s) ?>
<span class="node-badge"><?= ($s['perm'] ?? '') === 'write' ? '可读写' : '只读' ?></span>
<?php endforeach; ?>
<?php else: ?>
<span class="small muted">(还没有共享给谁)</span>
<?php endif; ?>
</div>
</div>
</details>
<?php endforeach; ?>
</div>
<?php /* 用户第 10 条:截断的地方要留一个明确的出口,别让人以为"就只有这 10 条"。 */ ?>
<?php if (!$showAll && $totalAll > count($rows)): ?>
<div class="res-more">
<a class="btn btn-ghost btn-sm"
href="<?= e(url('resources', ['sort' => $sort, 'keyword' => $keyword, 'all' => 1])) ?>">
显示全部 <?= (int)$totalAll ?> 条
</a>
<span class="small muted">只按「<?= $sort === 'new' ? '最新新增' : '最近改动' ?>」取了前 <?= (int)$limit ?> 条 —— 找不到就直接搜。</span>
</div>
<?php endif; ?>
<?php endif; ?>
</div>
<?php /* ==================== 四个动作的弹框(全页各一份)====================
与名册的 #rosterMenus、财务的 #ledPanels 同一套:
行上只留按钮,真正的 <form> 集中在这里渲染**一份**。
⚠️ `[hidden]` 必须自己声明:`.res-modal` 与 `.res-panel-lg` 都写了 `display:flex`,
浏览器默认的 `[hidden]{display:none}` 会被它们盖过 —— 于是"关不掉"。
(项目在 `.pv` / `.pp-item` 上各栽过一次,所以 CSS 里 `.res-modal[hidden]` 是硬要求。) */ ?>
<?php if ($canWrite): ?>
<div class="res-modal" id="resPanels" hidden>
<div class="res-mask" data-res-close></div>
<?php /* ---------- 编辑(一个弹框,三个页签:信息 / 附件 / 共享)----------
🔴 v28 用户第 7 条原话:「资源功能,编辑和附件两个按钮合并吧,就叫编辑,
既可以更改信息,也可以删除和增加附件,之前分开,操作的时候,还是有割裂感。
共享也合并,这样就剩下两个按钮,编辑和删除。」
⇒ 三块内容合成一个面板,用**页签**分隔。为什么用页签而不是把它们堆成一长条:
· 三块各是一套表单(信息 / 上传 / 共享),堆起来用户要滚很久才找得到;
· 页签让"我现在在改哪一块"一目了然,也避免了"三套表单同时铺在眼前"的压力。
⚠️ 只有 info 那一页是**本来就有的表单**,附件与共享两块复用了原来那两份表单,
服务端路由**一个都没改**(resources/update、attach_add、share、unshare)——
这是纯前端的信息架构调整,不动数据契约,也不动权限。
⚠️ 共享那块要的"已共享给谁"由脚本按按钮上的 data-res-shares 现渲染,
全页仍然只有**一份** #resUnshareForm(沿用原设计,别改回每条一份)。 */ ?>
<div class="res-panel-lg res-panel-tabs" id="resEditPanel" hidden role="dialog" aria-modal="true" aria-label="编辑资源">
<div class="res-ph"><b>「<span class="rd-title"></span>」</b>
<button type="button" class="res-x" data-res-close aria-label="关闭">✕</button></div>
<?php /* 页签条 */ ?>
<div class="res-tabs" role="tablist">
<button type="button" class="res-tab is-on" data-res-tab="info" role="tab">信息</button>
<button type="button" class="res-tab" data-res-tab="att" role="tab">附件</button>
<button type="button" class="res-tab" data-res-tab="share" role="tab">共享</button>
</div>
<?php /* ---- 页签 ① 信息 ---- */ ?>
<div class="res-tabpage" data-res-page="info">
<form method="post" data-res-idform action="<?= e(url('resources/update')) ?>">
<?= csrf_field() ?>
<input type="hidden" name="id" value="">
<div class="res-pb">
<div class="field">
<label>归属于谁 *</label>
<?php
$ppName = 'owner_id';
$ppPeople = $people;
$ppSelected = [];
$ppMode = 'single';
$ppOpenBtn = '更换归属人';
$ppTitle = '资源归属于谁';
$ppEmptyText = '未选择';
require __DIR__ . '/../_person_picker.php';
?>
</div>
<div class="field">
<label>标题 *</label>
<input class="input" name="title" required>
</div>
<div class="field">
<label>正文</label>
<textarea class="input" name="content" rows="3"
placeholder="编号、有效期、放在哪里…"></textarea>
</div>
</div>
<div class="res-pf">
<button class="btn btn-warn" type="submit">保存</button>
<button class="btn btn-ghost" type="button" data-res-close>取消</button>
</div>
</form>
</div>
<?php /* ---- 页签 ② 附件(原「附件」按钮那整套搬进来,一字未改)---- */ ?>
<div class="res-tabpage" data-res-page="att" hidden>
<div class="res-pb">
<div class="rd-att-now">
<h3 class="res-sub">已有附件</h3>
<div class="rd-att-list" id="resAttNow"></div>
</div>
<form method="post" enctype="multipart/form-data"
action="<?= e(url('resources/attach_add')) ?>" id="resAttForm" data-res-idform>
<?= csrf_field() ?>
<input type="hidden" name="id" value="">
<h3 class="res-sub">添加附件</h3>
<?php /* ⚠️ 自己的文件框 id(与"新建资源"里那个必须不同,同 id 会撞)。 */ ?>
<?php $sdName = 'attach[]'; $sdSlot = 'photo'; $sdId = 'sd-attach-panel';
require __DIR__ . '/../_stage_drop.php'; ?>
<div class="btn-row">
<button class="btn btn-info" type="submit">上传</button>
</div>
<p class="tip" style="margin:4px 0 0">
拖进来会**立刻上传**;点「上传」才真正挂到这条资源上。
</p>
</form>
</div>
</div>
<?php /* ---- 页签 ③ 共享(原「共享」按钮那整套搬进来)---- */ ?>
<div class="res-tabpage" data-res-page="share" hidden>
<div class="res-pb">
<form method="post" action="<?= e(url('resources/share')) ?>" data-res-idform>
<?= csrf_field() ?>
<input type="hidden" name="id" value="">
<div class="field">
<label>共享给谁</label>
<?php
$ppName = 'person_id';
$ppPeople = $people;
$ppSelected = [];
$ppMode = 'single';
$ppOpenBtn = '选择要共享给谁';
$ppTitle = '共享给谁';
$ppEmptyText = '未选择';
require __DIR__ . '/../_person_picker.php';
?>
<span class="tip">所有者本来就有全部权限,不用(也不能)共享给他。</span>
</div>
<div class="field">
<label>权限</label>
<select class="select" name="perm">
<option value="read">只读</option>
<option value="write">可读写</option>
</select>
</div>
<div class="btn-row" style="margin-top:0">
<button class="btn btn-ok" type="submit">共享</button>
</div>
</form>
<div class="res-share-now">
<h3 class="res-sub">已共享给</h3>
<div class="rd-share-list" id="resShareNow"></div>
</div>
</div>
<?php /* 取消共享:唯一的一份表单,脚本填两个隐藏字段后提交。 */ ?>
<form method="post" action="<?= e(url('resources/unshare')) ?>" id="resUnshareForm" data-res-idform hidden>
<?= csrf_field() ?>
<input type="hidden" name="id" value="">
<input type="hidden" name="person_id" value="">
</form>
</div>
<?php /* 删附件:唯一的一份表单,脚本填 id 后提交。 */ ?>
<form method="post" action="<?= e(url('resources/attach_del')) ?>" id="resAttDelForm" data-res-idform hidden>
<?= csrf_field() ?>
<input type="hidden" name="id" value="">
</form>
</div>
<?php /* ---------- ④ 删除(红色按钮)---------- */ ?>
<div class="res-panel-sm" id="resDelPanel" hidden role="dialog" aria-modal="true" aria-label="删除资源">
<div class="res-ph"><b>删除「<span class="rd-title"></span>」</b>
<button type="button" class="res-x" data-res-close aria-label="关闭">✕</button></div>
<form method="post" action="<?= e(url('resources/destroy')) ?>">
<?= csrf_field() ?>
<input type="hidden" name="id" value="">
<div class="res-pb">
<p class="del-big-tip" style="margin:0">
删除后<b>不能撤销</b>,这条资源连同它的
<b><span class="rd-attn">0</span> 个附件</b>会一起删掉。
</p>
</div>
<div class="res-pf">
<button class="btn btn-del" type="submit">确认删除</button>
<button class="btn btn-ghost" type="button" data-res-close>取消</button>
</div>
</form>
</div>
</div>
<?php endif; ?>
</div><?php /* /#resListHost */ ?>
<script>
(function () {
/* ---- 🔴 v41(用户第 9 条):「新建资源」改成**弹框** ----
⚠️ 原来是"页面里可开合的一块"(点一下展开、再点收起)。
现在它与「编辑」共用同一个弹框(`#resAddCard`),
所以打开时要清空上次的输入(否则会带着上一次的内容)。
⚠️ 提交走 AJAX + 只换列表容器:用户没要求,但"弹框里点确定却整页跳走"
会丢掉滚动位置;既然列表页已经有 `#resListHost` 这样的容器就顺手接上。 */
var addBtn = document.getElementById('resAddBtn');
var addCard = document.getElementById('resAddCard');
function setResOpen(on) {
if (!addCard) { return; }
addCard.hidden = !on;
if (on) {
if (window.fbUpgradeRange) { window.fbUpgradeRange(); }
if (window.fbBootAntd) { window.fbBootAntd(); }
var t = document.getElementById('rs-title');
if (t) { t.focus(); }
}
}
if (addBtn && addCard) {
addBtn.addEventListener('click', function () { setResOpen(true); });
}
['resAddX', 'resAddCancel'].forEach(function (id) {
var el = document.getElementById(id);
if (el) { el.addEventListener('click', function () { setResOpen(false); }); }
});
if (addCard) {
addCard.addEventListener('click', function (e) {
if (e.target === addCard) { setResOpen(false); } // 点遮罩关
});
}
document.addEventListener('keydown', function (e) {
if (e.key !== 'Escape' || !addCard || addCard.hidden) { return; }
var d = document.querySelector('.ant-picker-dropdown');
if (d && !d.classList.contains('ant-picker-dropdown-hidden')) { return; }
setResOpen(false);
});
/* 提交(click 主路 + submit 兜回车;理由见观影/旅程那两处注释) */
function resSave() {
var form = document.getElementById('resForm');
if (!form || !window.fetch || !window.FormData) { return false; }
var s = document.getElementById('resAddSubmit');
if (s) { s.disabled = true; s.textContent = '保存中…'; }
fetch(form.action, {
method: 'POST', body: new FormData(form),
headers: { 'X-Requested-With': 'XMLHttpRequest' },
credentials: 'same-origin',
}).then(function (r) {
return r.text().then(function (html) {
var doc = new DOMParser().parseFromString(html, 'text/html');
var fresh = doc.getElementById('resListHost');
var cur = document.getElementById('resListHost');
if (fresh && cur) { cur.innerHTML = fresh.innerHTML; }
else { window.location.reload(); return; }
setResOpen(false);
});
}).catch(function () {
if (s) { s.disabled = false; s.textContent = '确定'; }
form.submit();
}).then(function () {
if (s) { s.disabled = false; s.textContent = '确定'; }
});
return true;
}
document.addEventListener('click', function (ev) {
var t = ev.target;
if (!t || !t.closest || !t.closest('#resAddSubmit')) { return; }
if (resSave()) { ev.preventDefault(); }
});
document.addEventListener('submit', function (ev) {
if (ev.target && ev.target.id === 'resForm') {
if (resSave()) { ev.preventDefault(); }
}
});
})();
</script>
<script>
(function () {
/* ============================================================
动作 → 一份共享弹框(v24 第 23 批 用户第 1 条;
v28 第 7 条把「编辑 / 附件 / 共享」合成一个**带页签**的面板)
============================================================ */
var wrap = document.getElementById('resPanels');
if (!wrap) { return; }
/* ⚠️ v28:share 与 att 不再是独立面板,而是 edit 面板里的两个**页签**。
这里仍给它们留 key(都指向同一个 edit 面板),是为了让下面那段
"按 data-res-open 找面板"的老逻辑**一字不改**就能继续用 ——
卡片上若还有旧按钮(历史页面缓存)也不会打不开。
⚠️ 但 `show()` / `closeAll()` 遍历这些 key 时**必须先去重**:
三个 key 指向同一个元素,`k !== which` 会互相把对方设成隐藏,
最后一个说了算 → 点「编辑」弹框打不开(本轮真机实测抓到的真 bug)。
⚠️ 下面这几个 key 名故意**不写出旧按钮的 data 值**:这段是 JS,
会原样输出到每个用户的页面上,写出来等于把废弃钩子名发给大家
(也会让"旧钩子已清除"这类静态断言假红)。 */
var editPanel = document.getElementById('resEditPanel');
var panels = {
edit: editPanel,
share: editPanel,
att: editPanel,
del: document.getElementById('resDelPanel')
};
/** 把面板切到某一页签(tab: info / att / share) */
function tabTo(tab) {
if (!editPanel) { return; }
Array.prototype.forEach.call(editPanel.querySelectorAll('.res-tab'), function (b) {
b.classList.toggle('is-on', b.getAttribute('data-res-tab') === tab);
});
Array.prototype.forEach.call(editPanel.querySelectorAll('.res-tabpage'), function (p) {
p.hidden = (p.getAttribute('data-res-page') !== tab);
});
}
function closeAll() {
wrap.hidden = true;
// ⚠️ 同样按**去重后的面板**遍历(share/att 与 edit 是同一个元素,见 show 的注释)
var uniq = [];
Object.keys(panels).forEach(function (k) {
var p = panels[k];
if (p && uniq.indexOf(p) < 0) { uniq.push(p); }
});
uniq.forEach(function (p) { p.hidden = true; });
}
function show(which) {
/* ⚠️ 这里**不能**用 `k !== which` 去遍历 panels 设 hidden:
v28 把 share / att 两个 key 都指向**同一个** editPanel(它们现在是页签),
遍历时"share !== which"与"att !== which"会轮流把同一个面板设成隐藏,
**最后一个 key 说了算** → 点「编辑」时弹框根本打不开(本轮实测抓到的真 bug)。
⇒ 改成:只对**互不相同的面板**做显隐(用 Set 去重),语义也更直白。 */
var uniq = [];
Object.keys(panels).forEach(function (k) {
var p = panels[k];
if (p && uniq.indexOf(p) < 0) { uniq.push(p); }
});
uniq.forEach(function (p) { p.hidden = true; });
wrap.hidden = false;
var p = panels[which];
if (!p) { return; }
p.hidden = false;
var first = p.querySelector('input:not([type=hidden]), textarea, select');
if (first) { first.focus(); }
}
/* 页签点击:切页(不改 URL、不开新面板) */
if (editPanel) {
editPanel.addEventListener('click', function (e) {
var b = e.target && e.target.closest ? e.target.closest('[data-res-tab]') : null;
if (!b) { return; }
e.preventDefault();
tabTo(b.getAttribute('data-res-tab'));
});
}
/** 从 data 属性读 JSON(坏了就当空数组,别让一个坏属性把整个弹框搞死) */
function jsonOf(el, attr) {
try {
var v = JSON.parse(el.getAttribute(attr) || '[]');
return Array.isArray(v) ? v : [];
} catch (e) { return []; }
}
/** 建一个转义过的文本节点(**不拼 HTML 字符串** —— 名字来自用户输入) */
function el(tag, cls, text) {
var n = document.createElement(tag);
if (cls) { n.className = cls; }
if (text !== undefined) { n.textContent = text; }
return n;
}
var shareNow = document.getElementById('resShareNow');
var attNow = document.getElementById('resAttNow');
document.addEventListener('click', function (e) {
var t = e.target;
if (!t || !t.closest) { return; }
/* ---- 展开卡上的四个按钮 ----
🔴 必须 preventDefault:`<summary>` 内任意点击都会 toggle `<details>`,
不挡的话"点编辑 → 弹框开了、同时这一条也被展开了",看着像误触。
(stopPropagation 挡不住它 —— 那是**默认行为**,不是冒泡。) */
var op = t.closest('[data-res-open]');
if (op) {
e.preventDefault();
e.stopPropagation();
var which = op.getAttribute('data-res-open');
var id = op.getAttribute('data-res-id');
var title = op.getAttribute('data-res-title') || '';
var panel = panels[which];
if (!panel) { return; }
/* 🔴 v32 第 1 条真根因(用户:「弹框中,点击共享给某个人后,弹框消失,
但是并没有共享成功。」):
编辑面板现在是**三个页签**,每个页签有**自己的** `<form>` 和
**自己的** `<input name="id">`(info=resources/update、att=attach_add、
share=resources/share),面板末尾还有两个"唯一表单"(#resUnshareForm /
#resAttDelForm,也都带 name="id")。
而 v28 把三者并成一个面板时,这里写的是:
var idInp = panel.querySelector('input[name="id"]');
idInp.value = id; // ← 只填了**第一个**
`querySelector` 返回**文档序里第一个**(info 那个 / resources/update),
于是 att 与 **share** 两个页签的表单 `id` **一直是空** ⇒ 提交 `id=0` ⇒
后端 `Resource::find($familyId, 0, $me)` 返回 null ⇒ **静默不共享**
(只在列表页闪一句"这条资源不能共享。",用户多半没注意)。
⇒ 改成把 id 填进**这一屏里每一个会提交的表单**。
⚠️ 别写成"只填当前页签那份":用户会在弹框里切页签,
数据一次灌好、切页签就是纯显示切换(与上面注释同一取舍)。
⚠️⚠️ 判据用**显式标记**(`[data-res-idform]`),**不要**按 action 匹配:
`url()` 走 http_build_query,斜杠是 URL 编码的
(实测 `action="index.php?r=resources%2Fupdate"`),
写 `action*="resources/update"` 一条都匹配不上 —— 看着"改了",其实没改。 */
Array.prototype.forEach.call(
panel.querySelectorAll('form[data-res-idform]'),
function (f) {
var i = f.querySelector('input[name="id"]');
if (i) { i.value = id; }
});
// 每个面板都有的两样:标题回显 + 宿主 id
var tt = panel.querySelector('.rd-title');
if (tt) { tt.textContent = title; }
var idInp = panel.querySelector('input[name="id"]');
if (idInp) { idInp.value = id; }
/* 🔴 v28 第 7 条:「编辑 / 附件 / 共享」现在是**同一个面板的三个页签**。
所以这里对三者走同一段:把三块数据全灌好(哪块用不上就没人看),
最后切到对应的页签。
⚠️ 为什么"三块都灌"而不是"只灌当前页签那份":
用户在弹框里点页签来回切时,不该再回到按钮去取数据(数据在按钮上,
而按钮可能因为列表刷新换了一个);一次灌好,切页签就是纯显示切换。
⚠️ 旧 key(share / att)也要能落到正确页签 —— 历史页面缓存里可能还有
旧按钮,点了不该什么都看不到。 */
if (which === 'edit' || which === 'share' || which === 'att') {
var tInp = panel.querySelector('input[name="title"]');
if (tInp) { tInp.value = title; }
var cInp = panel.querySelector('textarea[name="content"]');
if (cInp) { cInp.value = op.getAttribute('data-res-content') || ''; }
// 归属人:选人组件是单选的,用它的对外接口设置。
// ⚠️ 必须把 panel 作为 root 传进去 —— 本页有多个同类组件
// (新建表单的 owner_id、本面板的 owner_id、共享页签的 person_id),
// 只按字段名找会命中**文档里第一个**(财务页在这上面栽过)。
var pid = op.getAttribute('data-res-owner') || '0';
if (window.ppSetSelected) { window.ppSetSelected('owner_id', pid, panel); }
// ---- 附件页签:渲染"已有附件"(名字 + 下载 + 删除)----
if (attNow) {
attNow.innerHTML = '';
var atts = jsonOf(op, 'data-res-atts');
if (!atts.length) {
attNow.appendChild(el('span', 'small muted', '还没有附件。'));
}
atts.forEach(function (a) {
var row = el('span', 'rd-att-row');
// 图片给一个缩略图,别的给类别词方块 —— 与展开区的附件墙观感一致
if (a.kind === 'image') {
var im = el('img', 'rd-att-thumb');
im.src = a.url;
im.alt = '';
row.appendChild(im);
} else {
row.appendChild(el('span', 'rd-att-ico', a.kw || '文件'));
}
row.appendChild(el('span', 'rd-att-name', a.name || a.kw || '附件'));
row.appendChild(el('span', 'small muted', a.sz || ''));
var dl = el('a', 'link-quiet', '下载');
dl.href = a.dl;
row.appendChild(dl);
var db = el('button', 'btn btn-ghost btn-sm', '删除');
db.type = 'button';
db.setAttribute('data-rd-attdel', String(a.id));
row.appendChild(db);
attNow.appendChild(row);
});
}
// ---- 共享页签:渲染"已共享给谁"。每行一个「取消共享」按钮 →
// 点它时把 id/person_id 灌进唯一的 #resUnshareForm 再提交。 ----
if (shareNow) {
shareNow.innerHTML = '';
var list = jsonOf(op, 'data-res-shares');
if (!list.length) {
shareNow.appendChild(el('span', 'small muted', '还没有共享给谁。'));
}
list.forEach(function (s) {
var row = el('span', 'rd-share-row');
row.appendChild(el('span', 'rd-share-name', s.name || ('#' + s.id)));
row.appendChild(el('span', 'node-badge', s.perm === 'write' ? '可读写' : '只读'));
var b = el('button', 'btn btn-ghost btn-sm', '取消共享');
b.type = 'button';
b.setAttribute('data-rd-unshare', String(s.id));
row.appendChild(b);
shareNow.appendChild(row);
});
}
// 三块数据都灌好了 → 切到该显示的页签
// ⚠️ 卡片上的「编辑」按钮统一进 info 页(用户点"编辑"想改的一般是信息);
// 历史缓存里的旧 share/att 按钮则落到各自页签。
tabTo(which === 'share' ? 'share' : (which === 'att' ? 'att' : 'info'));
show('edit');
return;
}
if (which === 'del') {
var n = op.getAttribute('data-res-n') || '0';
var nn = panel.querySelector('.rd-attn');
if (nn) { nn.textContent = n; }
}
show(which);
return;
}
/* ---- 取消共享(共享面板里现渲染出来的行)---- */
var un = t.closest('[data-rd-unshare]');
if (un) {
e.preventDefault();
var uf = document.getElementById('resUnshareForm');
if (!uf) { return; }
uf.querySelector('input[name="person_id"]').value = un.getAttribute('data-rd-unshare');
uf.submit();
return;
}
/* ---- 删附件(附件面板里现渲染出来的行)---- */
var ad = t.closest('[data-rd-attdel]');
if (ad) {
e.preventDefault();
if (!window.confirm('删掉这个附件?')) { return; }
var df = document.getElementById('resAttDelForm');
if (!df) { return; }
df.querySelector('input[name="id"]').value = ad.getAttribute('data-rd-attdel');
df.submit();
return;
}
if (t.closest('[data-res-close]')) { e.preventDefault(); closeAll(); }
});
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && !wrap.hidden) { closeAll(); }
});
})();
</script>