Files
FamilyBook/templates/resources/index.php
T
laoyang ae3060f552 v24 第 23 批:资源页四色按钮+四个弹框 / 财务页类别标签联动上下两图
用户 2 条原话:
  ①「资源页面,还可以更简洁,资源打开后,没有正文就不显示(没有正文)这句话了,
     直接显示附件。附件列完后,列出来人员,所有者、共享给谁了。然后其他的都不要。
     把编辑、共享、附件、删除,四个按钮,放在每个资源的右上角,和标题一排,
     点击后分别是编辑信息、设置共享人员,包括删除共享人等,附件,点击后弹框,
     允许用户拖动上传。编辑黄色、共享绿色、附件蓝色、删除红色。
     按钮点击后的功能,都是弹框。」
  ②「财务,统计功能和金额变化图表那里,中间,有各类标签,存款保险等。现在点击后
     没有任何效果,这个需要做处理,点击任何一个标签,则统计这个标签下的所有财务条目的
     总数,并在上下两个图上,画出来。」

用户拍板的两个岔路(AskUserQuestion):
  · 筛选某个类别时,上面那三个总数(总资产/总负债/净资产)**保持整本账口径**,
    不跟着切 —— 避免屏幕上只剩一个类别、却被误读成「我家只有存款」。
    当前看的是哪一类,由概览卡里一条高亮条说明(含合计与笔数 + 清除筛选)。
  · 「附件」弹框**既能拖动上传、也列出已有附件并可删**(附件管理只留一个入口);
    展开区的附件墙因此变成**只读**。

════ 一、 资源页(templates/resources/index.php 重写)════

**展开区只留「看」**(用户:"然后其他的都不要"):
  · 正文 —— **没有就不占位**(不再写「(没有正文)」那句话);
  · 附件墙 —— 只读(可预览/下载,不再带删除按钮);
  · 人员行 —— 列在附件之后:**所有者 + 共享给谁**。
原来堆在展开区底部的四坨内联表单(补传附件 / 共享设置 / 编辑 / 删除)**全部撤掉**。
⇒ 全页 <form> 的数量**不再随条目数增长**。这不是洁癖:账目页与名册页都因为
  「每行内联一套表单」被密码管理器(Bitwarden)逐个扫描、把主线程占死过两次,
  是用户报过的真问题。资源页这次一次做对。

**标题行右上角四色按钮**(用户点名配色):
  编辑 `btn-warn`(黄)· 共享 `btn-ok`(绿)· 附件 `btn-info`(蓝,本轮新增该类)·
  删除 `btn-del`(红)。四个都在 `<span class="res-ops">` 里,`margin-left:auto` 吸附右端。

**四个动作全在弹框里**(全页只有一份 `#resPanels`):
  · 编辑 —— 标题 / 正文 / 归属人(用选人组件的对外接口灌数据,且**限定在弹框内**查找
    ——本页有多个同名字段,只按名字找会命中第一个,财务页在这上面栽过);
  · 共享 —— 选人 + 权限(只读/可读写)+ **已共享给谁、逐个「取消共享」**
    (用户原话:「设置共享人员,**包括删除共享人**等」);
  · 附件 —— 已有附件(名字 / 大小 / 下载 / 删除)+ **拖动上传区**
    (复用全站 `_stage_drop.php`:点击或拖动、拖进来立刻上传);
  · 删除 —— 说明「不能撤销,会连 N 个附件一起删」,红色确认按钮。

三条结构性约束(断了浏览器会重新解析 HTML,症状很隐蔽):
  ① `<summary>` 里只能放 **phrasing content** → 按钮组必须是 `<span>`,`<form>` 绝对不能进
     (这也是「四个动作必须进弹框」的一条额外理由:它们本来全是 form);
  ② 点按钮必须 `preventDefault()` —— `<summary>` 内任意点击都会 toggle `<details>`;
     `stopPropagation()` 挡不住它(那是**默认行为**,不是冒泡);
  ③ 弹框容器自己声明了 `display:flex`,就必须补 `[hidden]{display:none}` 规则,
     否则「关不掉」(项目在 `.pv` 与 `.pp-item` 上各栽过一次)。

════ 二、 财务页(templates/ledger/index.php + Ledger.php + chart.php)════

🔴 **「点击后没有任何效果」的根因(本轮定位)**:
  图表卡那条类别 chip 的 JS 在找 `[data-ck-card]` 这个属性来显隐「每账目一张小卡」,
  而 v24 第 11 批 #7 按用户要求把「每账目一张小卡」换成了**一张合并的多线图** ——
  属性随之消失,**而这段 JS 没人删**。于是点标签:`.is-on` 会亮、画面上什么都不变。
  典型的「看着有交互(按钮会变蓝)、实际没作用」:**JS 找的钩子已经不存在了**。
  另外,概览卡里那些标签(存款/基金/保险…)原来是**纯展示的 `<span>`**,压根不可点 ——
  用户说的"中间,有各类标签"多半也指它。本轮把两处**并成同一套筛选入口**。

一处 `applyKind()` 同时做四件事(缺一件用户就会觉得"没生效"):
  ① 两处标签条的高亮**同步**(它们是同一个筛选器的两个入口,只给一处绑事件必然状态不一致);
  ② 上面那张「总金额」图切到**该类别的曲线**(按类别**预渲染**好,靠 hidden 切换
     —— 图是服务端画的 SVG,点了再请求就等于整页刷新,那就不是"点标签"是"点链接"了);
  ③ 下面那张多线图只留该类的线(`chart_multi` 已给每条线标了 `data-ck-line`)
     + 图例同步;
  ④ 下面账目表只列该类条目(`data-ck-row`)。
另外加一条概览高亮:「正在看『存款』:合计 ¥X · N 笔」+「清除筛选」。

服务端口径:
  · `Ledger::timeline($ledgerId, $kind = '')` —— **在取时点之前**按类别过滤。
    ⚠️ 反面做法(先算整本再筛)会把「被筛掉那些账目的创建日」留在时点集合里,
       图右端就凭空多出几个"其实什么都没变"的时点;
  · `Ledger::summary()` 顺手给出 `by_kind_n`(每类**笔数**)——
    用户要的「统计这个标签下的所有财务条目的总数」;
  · `chart_multi` 给每条线包一个 `<g data-ck-line="kind">` —— **纯标注、不改几何**。
    同一份数据多次渲染必须逐字节一致,所以属性值只能是 kind 本身,不许拼时间戳之类的。
  · 三个总数字**不随筛选变**(用户已确认)。

════ 三、 本轮修掉的三个真问题(都不在"功能"层面)════

① 🔴 **覆盖式改 CSS 漏了子属性 → 四个按钮竖排、标题行被撑到 175px 高**
   `.res-ops` 有一条**旧规则** `flex-direction: column`(那是给"展开区四个内联表单竖排"
   写的)。我改完模板后只在文件末尾**追加**了 `display / gap / margin-left`,
   **没写 `flex-direction`** → 四个按钮仍然竖着排。
   真机几何实测:`ops.top=382.9` vs `title.top=445.6`(差 63px)、标题行高 175px。
   ⇒ **改一个类的语义时,旧规则要就地改/删,不要在后面叠一条覆盖** ——
     叠加的写法天生容易漏子属性,而且**下一次改的人只会看到其中一处**。
     这次把那条规则就地改掉,并删掉确已废弃的 `.res-size` / `.res-share-add`。

② 🔴 **静态断言被注释里的字面串撑着 → 恒绿(比恒红危险)**
   部署断言原本是 `grep -qF 'class="res-ops"' 模板`。反证时我把**真代码**改坏,
   断言**照样通过** —— 因为模板注释里(解释"为什么按钮组用 span"时)引用了同一个字面串。
   **恒红会被发现,恒绿会一路伪装成"有覆盖"。**
   ⇒ 判据锚定到代码行(`grep -qE '^[[:space:]]+<span class="res-ops">'`),
     并且注释里不再写完整字面串。

③ **`str.replace(old, new, 0)` 是「替换零次」** —— 写反证脚本时我给某例传 `0`
   想表示"替换全部出现",实际什么都没改 → 报"断言没牙齿",白查一轮。全部替换是 `-1`。

════ 四、 验证 ════
- e2e **1171/0**(本批新增 55 条)· 真机浏览器探针 **45/0**(新增)
- 部署静态断言空跑 **309/0** · 桌面几何 428/0 · 移动端几何(360/375/390/414)684/0
- 老库结构升级 149/0
- **反向验证 18 例**(`_fb_b23_falsify.py`):按钮组拿掉 / 附件按钮摘掉蓝类名 /
  弹框容器拿掉 / 写回「(没有正文)」/ 两处上传区 id 撞名 / 取消共享表单删掉 /
  按钮组改用 div / 去掉 preventDefault / 删掉 [hidden] 规则 /
  写回 flex-direction:column / 「清除筛选」不响应 / 线条不标类别 /
  上图不预渲染 / 账目行不标类别 / timeline 去掉类别筛 / summary 不给笔数 /
  部署断言两条 —— 逐条改回旧行为,点名断言都变红,还原后复绿。

真机探针验的是**行为与几何**(curl 级断言看不见的东西):
  四按钮横排且贴标题行右端 · 点按钮**不会顺带展开**这一条 ·
  弹框 ✕/Esc/遮罩 三种方式都关得掉 · 点标签后**上面那张图真的换了**、
  下面多线图只剩该类 · 账目表跟着收窄 · 高亮条出现且「清除筛选」能回到全部 ·
  375px 窄屏两页都不横向溢出。

════ 五、 新增/改动文件 ════
  重写 `templates/resources/index.php`
  `templates/ledger/index.php`(筛选条合并 + 预渲染各类曲线 + applyKind)
  `app/Ledger.php`(timeline 加类别参数 + summary 补 by_kind_n)
  `app/chart.php`(chart_multi 给线条标 data-ck-line)
  `index.php`(ledger 路由多传 $kindLines)
  `assets/css/app.css`(.res-ops 就地改 + 四色按钮 + 弹框外壳 + .led-focus + 窄屏)
2026-10-06 20:41:04 +08:00

645 lines
30 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): ?>
<div class="card card-pad" id="resAddCard" <?= $isEditing ? '' : 'hidden' ?>>
<h2 class="cinema-h2"><?= $isEditing ? '编辑资源' : '新建资源' ?></h2>
<form method="post" enctype="multipart/form-data"
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="form-grid">
<div class="field">
<label>归属于谁 *</label>
<?php /* v24 第 11 条:与全局选人组件统一(原来是下拉,名册一长就难找) */ ?>
<?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 class="field">
<label for="rs-title">标题 *</label>
<input class="input" id="rs-title" name="title" required
value="<?= e($editing['title'] ?? '') ?>" placeholder="如:身份证 / 学位证">
</div>
<div class="field full">
<label for="rs-content">正文</label>
<textarea class="input" id="rs-content" name="content" rows="3"
placeholder="编号、有效期、放在哪里…"><?= e($editing['content'] ?? '') ?></textarea>
</div>
<div class="field full">
<label for="sd-attach">附件(可多选)</label>
<?php /* ⚠️ 新建时资源还不存在,附件只能在这里一起提交(建完再挂)。
列表里那条的附件管理则走右上角蓝色的「附件」弹框。
两处的文件框 id 必须**不同**(同 id 会撞),所以这里显式给一个。 */
$sdName = 'attach[]'; $sdSlot = 'photo'; $sdId = 'sd-attach';
require __DIR__ . '/../_stage_drop.php'; ?>
</div>
</div>
<div class="btn-row">
<button class="btn" type="submit"><?= $isEditing ? '保存' : '新建' ?></button>
<?php if ($isEditing): ?>
<a class="btn btn-ghost" href="<?= e(url('resources')) ?>">取消</a>
<?php endif; ?>
</div>
</form>
</div>
<?php endif; ?>
<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 /* ---- 右上角四色按钮(与标题同一排)----
⚠️ 必须用 <span> 包(phrasing content);`<div>`/`<form>` 放 <summary> 里是非法 HTML。
⚠️ 每个按钮都带 `data-res-open`,脚本里对它 `preventDefault()`
以阻止 <summary> 顺带把这一条展开。 */ ?>
<?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'] ?>">编辑</button>
<button type="button" class="btn btn-ok btn-sm res-op"
data-res-open="share" data-res-id="<?= $rid ?>"
data-res-title="<?= e((string)$r['title']) ?>"
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)) ?>">共享</button>
<button type="button" class="btn btn-info btn-sm res-op"
data-res-open="att" data-res-id="<?= $rid ?>"
data-res-title="<?= e((string)$r['title']) ?>"
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 /* ---------- ① 编辑信息(黄色按钮) ---------- */ ?>
<div class="res-panel-lg" 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>
<form method="post" 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>
<p class="tip" style="margin:0">
附件不在这里改 —— 用蓝色「附件」按钮(那边可以拖动上传、也能删)。
</p>
</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 /* ---------- ② 共享设置(绿色按钮)----------
用户原话:「设置共享人员,**包括删除共享人**等」→ 一个面板里两件事:
· 上面:给谁开共享(选人 + 权限 + 共享按钮)
· 下面:已经共享给谁 + 逐个「取消共享」
⚠️ "已共享"那一段由脚本按按钮上的 `data-res-shares` 现渲染,
**不是**每条资源渲染一份表单 —— 那正是要避免的膨胀。
取消共享走下面这个唯一的 #resUnshareForm(脚本填 id/person_id 后提交)。 */ ?>
<div class="res-panel-lg" id="resSharePanel" 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>
<div class="res-pb">
<form method="post" action="<?= e(url('resources/share')) ?>">
<?= 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" hidden>
<?= csrf_field() ?>
<input type="hidden" name="id" value="">
<input type="hidden" name="person_id" value="">
</form>
</div>
<?php /* ---------- ③ 附件(蓝色按钮)----------
用户原话:「附件,点击后弹框,允许用户拖动上传」。
→ 上:已有附件(可删);下:拖动/点击上传区(复用全站那套 `_stage_drop.php`,
框 + 立刻上传 + 暂存令牌,与其它模块完全一致)。 */ ?>
<div class="res-panel-lg" id="resAttPanel" 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>
<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">
<?= 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>
<?php /* 删附件:唯一的一份表单,脚本填 id 后提交。 */ ?>
<form method="post" action="<?= e(url('resources/attach_del')) ?>" id="resAttDelForm" 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; ?>
<script>
(function () {
/* ---- 「新建资源」开合(与旧版一致)---- */
var addBtn = document.getElementById('resAddBtn');
var addCard = document.getElementById('resAddCard');
if (addBtn && addCard) {
addBtn.addEventListener('click', function () {
addCard.hidden = !addCard.hidden;
addBtn.textContent = addCard.hidden ? '新建资源' : '收起';
if (!addCard.hidden) { addCard.scrollIntoView({ block: 'nearest' }); }
});
}
/* ============================================================
四个动作 → 一份共享弹框(v24 第 23 批 用户第 1 条)
============================================================ */
var wrap = document.getElementById('resPanels');
if (!wrap) { return; }
var panels = {
edit: document.getElementById('resEditPanel'),
share: document.getElementById('resSharePanel'),
att: document.getElementById('resAttPanel'),
del: document.getElementById('resDelPanel')
};
function closeAll() {
wrap.hidden = true;
Object.keys(panels).forEach(function (k) {
if (panels[k]) { panels[k].hidden = true; }
});
}
function show(which) {
Object.keys(panels).forEach(function (k) {
if (panels[k]) { panels[k].hidden = (k !== which); }
});
wrap.hidden = false;
var p = panels[which];
if (!p) { return; }
var first = p.querySelector('input:not([type=hidden]), textarea, select');
if (first) { first.focus(); }
}
/** 从 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; }
// 每个面板都有的两样:标题回显 + 宿主 id
var tt = panel.querySelector('.rd-title');
if (tt) { tt.textContent = title; }
var idInp = panel.querySelector('input[name="id"]');
if (idInp) { idInp.value = id; }
if (which === 'edit') {
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 (which === 'share') {
// 渲染"已共享给谁"。每行一个「取消共享」按钮 →
// 点它时把 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);
});
}
}
if (which === 'att') {
// 渲染"已有附件"(名字 + 下载 + 删除)
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);
});
}
}
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>