Files
laoyang e733c4f057 fix(resources): 新建资源时带着上一次的附件(暂存清单从不复位)(#19)
用户原话:「资源模块,新建资源,会把之前的附件带进来,就是说,附件那里不是空的。」

## 根因

`#resAddCard` 是新建/编辑**共用**同一个弹窗(v42 第 7 条),
复位函数 `resetToCreate()` 清了标题/正文/归属人/共享人,**唯独漏了附件**:

- 缩略图卡片与隐藏令牌 `stage_token[]` 是 queueDrop() 上传成功后
  **动态插进 `.stage-list`** 的(assets/js/app.js:668),只有点卡片上的「×」才移除;
- 关弹窗只做 `addCard.hidden = true`,DOM 原样留着 ⇒ 下次打开就看见上一次的附件。

## 不只是"看着脏"

上传后**没保存就关掉**的那批,暂存目录还在(没被认领),令牌残留在表单里
⇒ 下次点「确定」时被 `StagedUpload::claim()` 认领到**新资源**上。

实测(`_tooling/_b36_res_attach_reset.py` 的 D1):
修复前新建的资源 id=3 附件数 **=1** —— 用户会看到"新建的资源莫名多了张照片"。
修复后同一路径附件数 **=0**。

## 改法

新增 `clearStaged()`:清 `.stage-cell` 卡片 + 兜底清 `input[name="stage_token[]"]`
+ 把 `.stage-list` 藏回 hidden(与模板初始状态一致)。
两处调用:

- `resetToCreate()` —— 点「新建资源」时
- `openEdit()` —— 点「编辑」时(openEdit 并不灌附件,已有附件走列表里独立的
  「附件」弹窗,所以此刻表单里的卡片一定是"上一次没做完的残留")

⚠️ 兜底清令牌这步不能省:用户看不见令牌,但它才是真正会被提交的东西
(DOM 删了、数据还在 —— #456 同族)。

## 验证

`_tooling/_b36_res_attach_reset.py`(走真实 UI,三条关闭路径 A/B/C + 数据层 D):

| | 修复前 | 修复后 |
|---|---|---|
| A 取消后再新建(卡片/令牌) | 1 / 1 | **0 / 0** |
| B 保存成功后再新建 | 1 / 1 | **0 / 0** |
| C Esc 关闭后再新建 | 1 / 1 | **0 / 0** |
| D 新建资源的实际附件数 | **1**(带进来了) | **0** |
| 合计 | 通过 6 / **失败 7** | **通过 13 / 失败 0** |

**判据有牙齿**:`_tooling/_b36_falsify.py` 把 `clearStaged()` 注入成第一行 `return;`
(等价修复前),判据立刻红回 7 条,恢复后 13/0。锚点命中数断言 =1。

`_tooling/_b36_res_regress.py` —— 回归 13/0,确认没弄坏既有能力:
编辑仍能灌入标题/正文/id、底栏「共享」「附件」两颗按钮仍显示、
「新建+上传+保存」附件照旧真的挂上(附件数=1)、
列表里独立的附件面板组件(`sd-attach-panel`)仍在。

顺带查了其它用 `_stage_drop.php` 的模块(books/calendar/movies/people/tasks/trips),
都没有"共用弹窗 + 复位"这套结构,同一个坑不存在。

工单 #19
2026-10-28 17:50:51 +08:00

1058 lines
55 KiB
PHP
Raw Permalink 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 /* 🔴 v42(用户 2026-10-11 第 7 条):「资源编辑的弹框,复用新建资源的弹框,
保持一致。」⇒ 这个 `id` 隐藏框**始终存在**(新建时为空、编辑时填 id),
点列表里的「编辑」就把它填上、并把表单 action 切成 resources/update。
这样"新建 / 编辑"真的走同一个弹框、同一套排版与字段。
⚠️ 原来 `$isEditing` 时才输出它,导致"列表里点编辑"没法复用(只能去旧的
页签面板 `#resEditPanel`)—— 那两套界面不一样,正是用户报的问题。 */ ?>
<input type="hidden" name="id" id="rs-id" value="<?= $isEditing ? (int)$editing['id'] : '' ?>">
<div class="mv-form-inner">
<div class="mv-grid">
<div class="mv-row">
<span class="mv-lb is-req">归属于谁</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 is-req">标题</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>
<?php /* 🔴🔴 v42(用户第 7 条改动的**必要补充**):
原来「编辑」按钮打开的是**带页签的面板**(信息 / 附件 / 共享,v28 设计),
第 7 条要求"编辑复用新建那个弹框" ⇒ 编辑改到 `#resAddCard` 之后,
**共享 / 附件 这两个能力就没有入口了**(列表里只剩「编辑」「删除」两颗按钮)。
用户当年(v28 第 7 条)的原话是:
「编辑和附件两个按钮合并吧,就叫编辑,既可以更改信息,
也可以删除和增加附件…共享也合并,这样就剩下两个按钮,编辑和删除。」
⇒ 这两项能力**必须保留**,所以把它们放进新弹框的底栏:
编辑已有资源时,底栏多出「共享」「附件」两颗按钮,
点了就去开那个页签面板的对应页(能力一字未减,入口更明白)。
⚠️ 只有**编辑态**(有 id)才需要它们 —— 新建时资源还不存在,
没有"共享给谁""挂哪些附件"可说。 */ ?>
<button class="btn btn-sm btn-ghost" type="button" id="resToShare" hidden
data-res-pane="share">共享</button>
<button class="btn btn-sm btn-ghost" type="button" id="resToAtt" hidden
data-res-pane="att">附件</button>
<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 class="is-req">归属于谁</label>
<?php
$ppName = 'owner_id';
$ppPeople = $people;
$ppSelected = [];
$ppMode = 'single';
$ppOpenBtn = '更换归属人';
$ppTitle = '资源归属于谁';
$ppEmptyText = '未选择';
require __DIR__ . '/../_person_picker.php';
?>
</div>
<div class="field">
<label class="is-req">标题</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');
/* 🔴 工单 #19(用户 2026-10-28):「资源模块,新建资源,会把之前的附件带进来,
就是说,附件那里不是空的。」
【根因】`#resAddCard` 是新建/编辑**共用**同一个弹窗(v42 第 7 条),
但附件区**从来没被复位过**:
· 缩略图卡片与隐藏令牌 `stage_token[]` 是 queueDrop() 上传成功后
**动态插进 `.stage-list`** 的(assets/js/app.js:668),
只有点卡片上那个「×」才会移除;
· 关弹窗只做 `addCard.hidden = true`,DOM 原样留着 ⇒ 下次打开就看见上一次的附件。
⇒ 复位函数 `resetToCreate()` 清了标题/正文/归属人/共享人,**唯独漏了附件**。
⚠️ 这不只是"看着脏":上传后**没保存就关掉**的那批,暂存目录还在(没被认领),
令牌残留在表单里 ⇒ 下次点「确定」时会被 `StagedUpload::claim()` 认领到
**新资源**上 —— 实测「新建的资源莫名多了 1 张照片」
(见 `_tooling/_b36_res_attach_reset.py` 的 D1)。
⚠️ 编辑态**也要清**:`openEdit()` 并不灌附件(已有附件走列表里独立的「附件」弹窗),
所以编辑时看到的任何附件都是"上一次没做完的残留" ⇒ 保存时会被挂到这条资源上。
⚠️ 为什么"只清 DOM"就够了:卡片在**拖入时**就已经 append 进 `.stage-list`
(app.js:600,同步执行),上传完成的回调是在那个**已脱离文档**的节点上
改样式、塞令牌,不会再回到表单里 ⇒ 不需要额外加"代/epoch"计数器。
⚠️ 除了卡片,还要兜底清 `input[name="stage_token[]"]`:
用户看不见令牌,但它才是真正会被提交的东西(#456 同族:DOM 删了、数据还在)。 */
function clearStaged() {
var form = document.getElementById('resForm');
if (!form) { return; }
var list = form.querySelector('[data-stage-list]');
if (list) {
Array.prototype.forEach.call(list.querySelectorAll('.stage-cell'), function (c) {
if (c.parentNode) { c.parentNode.removeChild(c); }
});
// 一张不剩就藏回去(模板初始就 hidden,别留一条空边框)
list.hidden = true;
}
Array.prototype.forEach.call(form.querySelectorAll('input[name="stage_token[]"]'),
function (i) {
if (i.parentNode) { i.parentNode.removeChild(i); }
});
var tip = form.querySelector('[data-stage-tip]');
if (tip) { tip.textContent = ''; tip.hidden = true; }
}
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 () {
// 🔴 「新建」前先复位成"新建态"(上一次点过「编辑」的话表单已被灌过值)
resetToCreate();
setResOpen(true);
});
}
/* ============================================================
🔴 v42(用户 2026-10-11 第 7 条):「资源编辑的弹框,复用新建资源的弹框,
保持一致。」—— 列表里那颗「编辑」按钮现在也开**同一个** `#resAddCard`:
· 把行上的 data-*(标题/正文/归属人)灌进表单;
· `id` 隐藏框填上这条的 id,表单 action 切成 `resources/update`;
· 标题栏文字换成「编辑资源」。
原来的旧页签面板(`#resEditPanel`,v28 那套)**不再是编辑入口** ——
它现在只承担"附件 / 共享"两个页签(那两块仍是它独有的能力)。
⚠️ 分享的预填:那一行 button 上带了 `data-res-shares`(JSON),
这里把它写进选人组件的隐藏字段;选人组件自己的 chip 由 fbPersonPicker 重画。
============================================================ */
var CREATE_ACTION = <?= json_encode(url('resources/create'), JSON_UNESCAPED_UNICODE) ?>;
var UPDATE_ACTION = <?= json_encode(url('resources/update'), JSON_UNESCAPED_UNICODE) ?>;
function setPickerValues(name, ids) {
if (!addCard) { return; }
/* 🔴 用全局选人组件自己的接口(`window.ppSetSelected`,见 person_picker.js)——
它内部会:建候选清单(懒建)→ 设 checked → 重画 chips。
自己手改隐藏字段是不够的:外面的 chip 排不会跟着变,用户看到"没带上"。
⚠️ 第三个参数把**本弹框**作为 root 传进去:页面上可能有多个选人组件,
不限定 root 会命中文档里**第一个**(这正是 v24 踩过的坑,见它的注释)。 */
if (typeof window.ppSetSelected === 'function') {
window.ppSetSelected(name, ids.map(String), addCard);
return;
}
// 兜底:组件没加载时至少把隐藏值写上(提交是对的,只是 chip 显示不出来)
var pp = addCard.querySelector('.pp[data-pp-name="' + name + '"]');
if (!pp) { return; }
Array.prototype.forEach.call(pp.querySelectorAll('input[type=hidden][name="' + name + '"]'),
function (i) { i.parentNode.removeChild(i); });
ids.forEach(function (id) {
var i = document.createElement('input');
i.type = 'hidden'; i.name = name; i.value = String(id);
pp.appendChild(i);
});
}
function openEdit(btn) {
if (!addCard) { return; }
var id = btn.getAttribute('data-res-id') || '';
var form = document.getElementById('resForm');
var idEl = document.getElementById('rs-id');
if (idEl) { idEl.value = id; }
if (form) { form.action = UPDATE_ACTION; }
var ttl = document.getElementById('resAddTitle');
if (ttl) { ttl.textContent = '编辑资源'; }
var t = document.getElementById('rs-title');
if (t) { t.value = btn.getAttribute('data-res-title') || ''; }
var c = document.getElementById('rs-content');
if (c) { c.value = btn.getAttribute('data-res-content') || ''; }
// 归属人(单选)
var owner = btn.getAttribute('data-res-owner') || '';
if (owner) { setPickerValues('owner_id', [parseInt(owner, 10)]); }
// 共享(多选)
var sh = btn.getAttribute('data-res-shares') || '[]';
var ids = [];
try {
var arr = JSON.parse(sh);
ids = arr.map(function (o) { return o.id; });
} catch (e) { ids = []; }
setPickerValues('share_ids[]', ids);
/* 🔴 工单 #19:编辑态**也要清**暂存区 —— openEdit 并不灌附件(已有附件走
列表里独立的「附件」弹窗,见下面的 openPane),所以此刻表单里若还有卡片/令牌,
那一定是"上一次拖进来没保存"的残留 ⇒ 不清的话会被挂到**这条资源**上。 */
clearStaged();
setResOpen(true);
/* 🔴 v42:编辑态才显示底栏的「共享」「附件」两颗按钮(新建时资源还不存在,
没有"共享给谁/挂哪些附件"可说)。 */
var bs = document.getElementById('resToShare');
var ba = document.getElementById('resToAtt');
if (bs) { bs.hidden = false; bs.setAttribute('data-res-id', id); }
if (ba) { ba.hidden = false; ba.setAttribute('data-res-id', id); }
}
/* 底栏的「共享」「附件」:**复用产品原有的入口** ——
它们合成一个 `[data-res-open]` 事件交给页签面板那段脚本处理
(那段会把 id 填进**每一个** `form[data-res-idform]`,见它的长注释)。
⚠️ 必须走产品那条路(而不是自己再写一遍"打开面板 + 灌 id"):
否则反向验证里"把 id 注入改回只填第一个"这个注入**打不到探针** ⇒
证伪失效(本轮实测就栽在这里 —— 探针绕过产品逻辑,等于没测)。
⚠️ 用 `click()` 派发到页签面板脚本会命中的元素上:页签面板脚本监听
`document` 上的 click 并读 `closest('[data-res-open]')`,
所以这里造一个临时的 `[data-res-open]` 元素来 click 最直接可靠。 */
function openPane(pane, id) {
var t = document.createElement('button');
t.type = 'button';
t.setAttribute('data-res-open', pane);
t.setAttribute('data-res-id', id);
t.style.display = 'none';
document.body.appendChild(t);
t.click();
t.parentNode.removeChild(t);
}
['resToShare', 'resToAtt'].forEach(function (bid) {
var el = document.getElementById(bid);
if (!el) { return; }
el.addEventListener('click', function () {
var pane = el.getAttribute('data-res-pane') || '';
var id = el.getAttribute('data-res-id') || '';
if (!pane || !id) { return; }
setResOpen(false); // 先收起新弹框(避免两层遮罩叠着)
openPane(pane, id);
});
});
function resetToCreate() {
var form = document.getElementById('resForm');
var idEl = document.getElementById('rs-id');
if (idEl) { idEl.value = ''; }
if (form) { form.action = CREATE_ACTION; }
var ttl = document.getElementById('resAddTitle');
if (ttl) { ttl.textContent = '新建资源'; }
var t = document.getElementById('rs-title'); if (t) { t.value = ''; }
var c = document.getElementById('rs-content'); if (c) { c.value = ''; }
/* 🔴 v42:回到"新建态"时收起底栏那两颗编辑专用按钮(见 openEdit 的注释) */
['resToShare', 'resToAtt'].forEach(function (bid) {
var el = document.getElementById(bid);
if (el) { el.hidden = true; el.removeAttribute('data-res-id'); }
});
setPickerValues('share_ids[]', []);
/* 🔴 工单 #19:附件也要清 —— 否则新建时带着上一次的附件,见 clearStaged 的注释 */
clearStaged();
}
/* 事件委托:列表里的「编辑」按钮(AJAX 换过列表后仍然有效) */
document.addEventListener('click', function (e) {
var t = e.target;
if (!t || !t.closest) { return; }
var b = t.closest('[data-res-open="edit"]');
if (!b) { return; }
e.preventDefault();
openEdit(b);
});
['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');
/* 🔴 v42(用户第 7 条):**「编辑」不再走这个旧页签面板** ——
它现在复用「新建资源」那个弹框(`#resAddCard`,见本文件前一个 script)。
那颗按钮的处理在那边(也在 document 上委托,先于本段执行)。
这里只管 `att` / `share` / `del` 三个动作的页签面板。
⚠️ 必须在这里让开:否则同一颗按钮会被两处都接住,
表现为"编辑弹框开了、旧的页签面板也弹出来"(两层遮罩叠在一起)。 */
if (which === 'edit') { return; }
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>