v24 第 11 批:7 大条 20 子项(首页/名册/观影/裁剪/日程/财务)
用户 2026-10-04 第 11 批,7 大条: - #1 首页:未来七天过滤掉「农历」(每日一条会淹掉真日程);右侧日历本标签 从灰字(.dash-tag)改成**实心色块**(底色=日历色、白字、text-shadow 兜底浅色)。 - #2 名册:分组改**居中模态框**(用户要"像选观影人一样")。修掉"点第二次失效" ——根因是旧实现用**捕获阶段**监听 document click 做"点外面就关",与触发按钮 的 click 打架;模态把关闭收敛到遮罩/✕/取消。卡片「编辑」后加「删除」 (→ people/destroy);详情页底部删除改成 btn-danger + btn-block-lg 大红按钮 + 两步确认。 - #3 名册详情页重排:.detail(260px+1fr) → .pcard(头像左上 + 右侧姓名/标签) + .detail-stack 全宽往下。旧布局头像卡比右栏矮,头像下留一大片空白。 - #4 观影:影院下拉换**自定义组件**(原生 <option> 画不了色块)——按钮+列表、 每行色点,值仍写隐藏 input[name=cinema_id];「管理影院」→「新建影院」弹框 (带 back=movies/new,存完回本页并 ?cinema=<id> 自动选中)。 修「开始/结束时间标签跑到控件下面」:upgradeRange 原来 insertBefore(host, group.firstChild) 把控件插到了 label 之上 —— 改成插在第一个原生控件之前。 - #5 裁剪改 **Cropper.js**(assets/vendor/cropper/,按需加载走 fb-cropper meta): 弹框 overflow:hidden + 固定画布(**无滚动条**)、**去掉缩放条**改滚轮 wheelZoomRatio、 viewMode:1 + autoCropArea 保证框小于画布、8 个触点全露。 结果仍写 *_crop = 归一化 x,y,w,h(提交契约不变)。 - #6 日程:内置节假日原来每天只标**一天**(国庆只出一条)→ FIXED 改成 [名字, 放假天数] 按区间铺;月视图从周日改**周一**起(表头同步); 跳转日期从"长条+原生框"改成**单个 btn-icon**,点开弹 Semi 日期选择器。 - #7 财务:单条曲线无盘点时只回一个点(图看着像全 0)→ 改成"从创建日(0)画到今天"; 图下条目从"每账目一张小图卡"改成**一张 chart_multi 多线图 + 颜色图例** (Ledger::LEGEND_COLORS 按 id 取模,保证颜色稳定);账目页卡顿同源治理 (行上只留按钮、表单全页一份 #ledPanels);修改按钮改弹框并回填归属人。 ## 顺带修的真 bug(本批最要紧的三条) 1. **观影表单提交静默失效**:「新建影院」弹框被误嵌在观影主表单 <form> 内。 HTML 不允许 form 嵌套 → 浏览器拆开内层,弹框里 required 的"影院名称"变成 主表单必填项、又因 hidden 无法聚焦 → 点「记下这场电影」什么都不发生 (Chrome: invalid form control with name='name' is not focusable)。 → 弹框移到主表单之后。部署脚本加防线:按顺序配平 form 开闭判断是否嵌套。 2. **裁剪取消没清空文件框**(换 Cropper 时丢)→ close(cancelled),4 个取消入口 传 true 清空;否则"点了取消"仍会把没裁的原图传上去。 3. **裁剪预览退化成原图**(同批丢)→ 用 canvas 按 Cropper 的原图像素矩形重画, toDataURL 写回预览,恢复"所见即所得"。 ## 连带修复:选人组件懒渲染(线上实测的必要性) 线上真实数据下账目页 6939 KB / 16788 个 input、观影表单 1194 个 input —— 根因是 _person_picker.php 首屏就把**全部候选人**渲成 <label>+<input>。 改成:首屏只留「不选」+ data-pp-people(JSON),**打开弹框时**才 buildList(); 无 JS 兜底走 <noscript>(HTML5 规定启用脚本时其内容按纯文本解析,不产生节点)。 新增 window.ppSetSelected / decorateItems(动态项要补算首字母)。 实测账目页 6939 KB → 544 KB。新增 _fb_picker_lazy_check.py(11/0 + 反向 2 红)。 验证:e2e 786/0、交互 90/0、半集成 27/0、batch_e 91/0、batch10 46/0、batch9 38/0、 crop 17/0(+反向)、picker_unify 28/0、picker_lazy 11/0(+反向)、formbloat 9/0、 upgrade 126/0、mobile 688/0、drag 5/0、geom 5/5。 线上自检 19/19;线上真实账号验收 7/0(含"主表单无隐藏必填""线上 Cropper 接管+取消清空")。
This commit is contained in:
+23
-7
@@ -42,13 +42,22 @@ final class Holiday
|
|||||||
public const CACHE_FAIL = 21600; // 6 小时
|
public const CACHE_FAIL = 21600; // 6 小时
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 公历固定日期的节日(月 => [日, 名字])。
|
* 公历固定日期的节日:月 => [日, 名字, **放假天数**]。
|
||||||
* 只放**日期固定**的:清明是节气(4月4/5/6日浮动)要算,不在这里。
|
* 只放**日期固定**的:清明是节气(4月4/5/6日浮动)要算,不在这里。
|
||||||
|
*
|
||||||
|
* ⚠️ v24 第 11 批 #6(用户报「国庆节只有刚开始的一天有,这不合理」):
|
||||||
|
* 这里原来只标**一天**,于是日历上国庆只有 10-01 那一条 —— 而法定假是 7 天。
|
||||||
|
* 现在把**法定放假天数**一起写上,内置规则按区间铺开。
|
||||||
|
* 天数按《全国年节及纪念日放假办法》的法定假口径:
|
||||||
|
* 元旦 3 天(通常 1/1 + 周末调休,保守写 1 天由官方数据补)、
|
||||||
|
* 劳动节 5 天、国庆节 7 天。
|
||||||
|
* ⚠️ 这里的数字是**内置兜底**(官方数据一到就整体替换,见 fetchAndMerge),
|
||||||
|
* 所以宁可保守也不要编造调休:调休只有官方才准。
|
||||||
*/
|
*/
|
||||||
private const FIXED = [
|
private const FIXED = [
|
||||||
1 => [1 => '元旦'],
|
1 => [1 => ['元旦', 3]],
|
||||||
5 => [1 => '劳动节'],
|
5 => [1 => ['劳动节', 5]],
|
||||||
10 => [1 => '国庆节'],
|
10 => [1 => ['国庆节', 7]],
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -204,10 +213,17 @@ final class Holiday
|
|||||||
{
|
{
|
||||||
$days = [];
|
$days = [];
|
||||||
|
|
||||||
// ---- 公历固定节日 ----
|
// ---- 公历固定节日:按**放假天数**铺成一段 ----
|
||||||
|
// ⚠️ 用户原话「国庆节只有刚开始的一天有,这不合理」——旧版每天只标一天。
|
||||||
foreach (self::FIXED as $m => $v) {
|
foreach (self::FIXED as $m => $v) {
|
||||||
foreach ($v as $d => $name) {
|
foreach ($v as $d => $spec) {
|
||||||
$days[sprintf('%04d-%02d-%02d', $year, $m, $d)] = ['name' => $name, 'off' => true];
|
[$name, $span] = $spec;
|
||||||
|
$start = sprintf('%04d-%02d-%02d', $year, $m, $d);
|
||||||
|
for ($i = 0; $i < max(1, (int)$span); $i++) {
|
||||||
|
$t = date('Y-m-d', strtotime($start . ' +' . $i . ' day'));
|
||||||
|
// 跨到下一年的(元旦 3 天可能压到 1/3)也照铺,键就是真实日期
|
||||||
|
$days[$t] = ['name' => $name, 'off' => true];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+42
-2
@@ -346,6 +346,28 @@ final class Ledger
|
|||||||
return ($neg ? '-' : '') . '¥' . $s;
|
return ($neg ? '-' : '') . '¥' . $s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 图例色板(v24 第 11 批 #7)。
|
||||||
|
*
|
||||||
|
* 用户原话:「图下面的条目不用显示详情,只需要显示每个账目的颜色,
|
||||||
|
* 就是说哪个颜色代表哪个账目,就可以了。」
|
||||||
|
*
|
||||||
|
* 所以每条账目要有一个**稳定**的颜色 —— 按 id 取模落在色板上,
|
||||||
|
* 不随排序/刷新变化(否则用户没法靠颜色认账目,图例就白做了)。
|
||||||
|
* 色板挑中低饱和的,压在宣纸底色上不刺眼,且与 .led-kind-chip 的观感一致。
|
||||||
|
*/
|
||||||
|
public const LEGEND_COLORS = [
|
||||||
|
'#B0342C', '#3F7D5A', '#7A4E8C', '#A65C30',
|
||||||
|
'#3078A6', '#8C6D3F', '#A6305C', '#4A7C82',
|
||||||
|
'#6BA630', '#9730A6', '#A68930', '#5B6B8C',
|
||||||
|
];
|
||||||
|
|
||||||
|
public static function legendColor(int $entryId): string
|
||||||
|
{
|
||||||
|
$n = count(self::LEGEND_COLORS);
|
||||||
|
return self::LEGEND_COLORS[(($entryId % $n) + $n) % $n];
|
||||||
|
}
|
||||||
|
|
||||||
public static function kind(string $k): string
|
public static function kind(string $k): string
|
||||||
{
|
{
|
||||||
return isset(self::KINDS[$k]) ? $k : 'other';
|
return isset(self::KINDS[$k]) ? $k : 'other';
|
||||||
@@ -543,14 +565,27 @@ final class Ledger
|
|||||||
*
|
*
|
||||||
* @return array<int,array{date:string,cents:int}>
|
* @return array<int,array{date:string,cents:int}>
|
||||||
*/
|
*/
|
||||||
public static function entrySeries(int $entryId, int $currentCents): array
|
public static function entrySeries(int $entryId, int $currentCents, string $born = ''): array
|
||||||
{
|
{
|
||||||
$rows = self::history($entryId);
|
$rows = self::history($entryId);
|
||||||
$out = [];
|
$out = [];
|
||||||
foreach ($rows as $r) {
|
foreach ($rows as $r) {
|
||||||
$out[] = ['date' => substr((string)$r['created_at'], 0, 10), 'cents' => (int)$r['amount_cents']];
|
$out[] = ['date' => substr((string)$r['created_at'], 0, 10), 'cents' => (int)$r['amount_cents']];
|
||||||
}
|
}
|
||||||
|
// ⚠️ v24 第 11 批 #7(用户报「那个图,怎么数据都是 0 啊,没有盘点数据,
|
||||||
|
// 就用账目的初始数据就可以了,不一定非要有盘点数据」):
|
||||||
|
// 旧版"没有任何盘点"时只回**一个点**(今天=当前值)——
|
||||||
|
// 单点折线图在页面上就是一根贴边的平线,两端标注还是同一个数,
|
||||||
|
// 看起来就像"数据都是 0"。用户要的是"用账目的初始数据画出来"。
|
||||||
|
// 所以:没盘点过 → 从**账目创建日(那时是 0)**画到今天(当前值),
|
||||||
|
// 这条线表达的正是"这笔钱从无到有"。
|
||||||
if (!$out) {
|
if (!$out) {
|
||||||
|
$start = $born !== '' ? $born : date('Y-m-d');
|
||||||
|
if ($start === date('Y-m-d')) {
|
||||||
|
// 今天建、今天看 → 给一个**前一天**当起点,否则还是单点
|
||||||
|
$start = date('Y-m-d', strtotime('-1 day'));
|
||||||
|
}
|
||||||
|
$out[] = ['date' => $start, 'cents' => 0];
|
||||||
$out[] = ['date' => date('Y-m-d'), 'cents' => $currentCents];
|
$out[] = ['date' => date('Y-m-d'), 'cents' => $currentCents];
|
||||||
}
|
}
|
||||||
return $out;
|
return $out;
|
||||||
@@ -579,7 +614,12 @@ final class Ledger
|
|||||||
$eid = (int)$e['id'];
|
$eid = (int)$e['id'];
|
||||||
$out[] = [
|
$out[] = [
|
||||||
'entry' => $e,
|
'entry' => $e,
|
||||||
'series' => self::entrySeries($eid, (int)$e['amount_cents']),
|
// 图例用的颜色:同一账目**每次都一样**(按 id 取模取色板),
|
||||||
|
// 否则刷新一次就换色,用户没法靠颜色认账目。
|
||||||
|
'color' => self::legendColor($eid),
|
||||||
|
'series' => self::entrySeries(
|
||||||
|
$eid, (int)$e['amount_cents'],
|
||||||
|
substr((string)($e['created_at'] ?? ''), 0, 10)),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
return $out;
|
return $out;
|
||||||
|
|||||||
+116
-1
@@ -131,4 +131,119 @@ function chart_line(array $series, string $label, string $kind = 'asset', int $w
|
|||||||
}
|
}
|
||||||
$out .= '</svg>';
|
$out .= '</svg>';
|
||||||
return $out;
|
return $out;
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* **多条线**的折线图(v24 第 11 批 #7)。
|
||||||
|
*
|
||||||
|
* 用户原话:「图下面的条目不用显示详情,只需要显示每个账目的颜色,
|
||||||
|
* 就是说哪个颜色代表哪个账目,就可以了。」
|
||||||
|
*
|
||||||
|
* 所以把原来"每条账目一张小图"合并成**一张图**:每条账目一条线、各自一个颜色,
|
||||||
|
* 颜色由图例给出(Ledger::legendColor())。这样一眼能比较"哪条在涨、哪条在跌",
|
||||||
|
* 而不是逐张小图去脑补 —— 那是用户嫌"显示详情"没必要的真正原因。
|
||||||
|
*
|
||||||
|
* ⚠️ 时间轴取所有曲线日期的**并集**,每条线在自己的日期上有值、
|
||||||
|
* 没有的日期就"沿用上一次的值"(阶梯式),否则线会断成一段段虚线。
|
||||||
|
* 这与 Ledger::timeline() 的口径一致:金额是"状态量",不是"发生量"。
|
||||||
|
*
|
||||||
|
* @param array $charts Ledger::charts() 的返回值:[['entry'=>…, 'color'=>…, 'series'=>…], …]
|
||||||
|
* @param int $width 画布宽
|
||||||
|
* @return string SVG(无可画内容时返回 '')
|
||||||
|
*/
|
||||||
|
function chart_multi(array $charts, int $width = 900): string
|
||||||
|
{
|
||||||
|
if (!$charts) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
// ---- 时间轴:所有日期的并集(升序)----
|
||||||
|
$dateSet = [];
|
||||||
|
foreach ($charts as $ch) {
|
||||||
|
foreach ((array)($ch['series'] ?? []) as $p) {
|
||||||
|
$d = (string)($p['date'] ?? '');
|
||||||
|
if ($d !== '') { $dateSet[$d] = true; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$dates = array_keys($dateSet);
|
||||||
|
sort($dates);
|
||||||
|
$n = count($dates);
|
||||||
|
if ($n === 0) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 值域:所有曲线所有点的 min/max(含 0,让"从 0 涨起来"看得出来)----
|
||||||
|
$vals = [0];
|
||||||
|
foreach ($charts as $ch) {
|
||||||
|
foreach ((array)($ch['series'] ?? []) as $p) {
|
||||||
|
$vals[] = (int)($p['cents'] ?? 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$min = min($vals); $max = max($vals);
|
||||||
|
$span = $max - $min;
|
||||||
|
if ($span <= 0) { $span = 1; }
|
||||||
|
|
||||||
|
$w = max(200, $width);
|
||||||
|
$h = (int)round($w / 3.6);
|
||||||
|
$padL = 8; $padR = 8; $padT = 16; $padB = 18;
|
||||||
|
$iw = $w - $padL - $padR;
|
||||||
|
$ih = $h - $padT - $padB;
|
||||||
|
|
||||||
|
$idxOf = array_flip($dates); // 日期 => 横轴序号
|
||||||
|
|
||||||
|
$out = '<svg class="ck-svg" viewBox="0 0 ' . $w . ' ' . $h . '" width="100%" height="' . $h . '"'
|
||||||
|
. ' role="img" aria-label="各账目金额变化" xmlns="http://www.w3.org/2000/svg">';
|
||||||
|
// 底板与基线
|
||||||
|
$out .= '<line x1="' . $padL . '" y1="' . ($padT + $ih) . '" x2="' . ($padL + $iw) . '"'
|
||||||
|
. ' y2="' . ($padT + $ih) . '" stroke="currentColor" stroke-opacity=".18" stroke-width="1"/>';
|
||||||
|
|
||||||
|
foreach ($charts as $ch) {
|
||||||
|
$series = (array)($ch['series'] ?? []);
|
||||||
|
if (!$series) { continue; }
|
||||||
|
$color = (string)($ch['color'] ?? '#B0342C');
|
||||||
|
// 把每个日期上的值铺齐(沿用上一次),再转成坐标
|
||||||
|
$pts = [];
|
||||||
|
$last = null;
|
||||||
|
foreach ($dates as $d) {
|
||||||
|
// 取"这一天或之前最后一次的值"(状态量口径,与 timeline 一致)
|
||||||
|
foreach ($series as $p) {
|
||||||
|
if ((string)($p['date'] ?? '') <= $d) { $last = (int)($p['cents'] ?? 0); }
|
||||||
|
else { break; }
|
||||||
|
}
|
||||||
|
if ($last === null) { continue; } // 这条线还没开始(创建日之前)
|
||||||
|
|
||||||
|
$i = $idxOf[$d];
|
||||||
|
$x = $n === 1 ? ($padL + $iw / 2) : ($padL + $iw * $i / ($n - 1));
|
||||||
|
$ratio = ($last - $min) / $span;
|
||||||
|
$y = $padT + $ih * (1 - $ratio);
|
||||||
|
$pts[] = [round($x, 2), round($y, 2)];
|
||||||
|
}
|
||||||
|
if (!$pts) { continue; }
|
||||||
|
|
||||||
|
if (count($pts) === 1) {
|
||||||
|
$out .= '<circle cx="' . $pts[0][0] . '" cy="' . $pts[0][1] . '" r="3" fill="' . $color . '"/>';
|
||||||
|
} else {
|
||||||
|
$poly = [];
|
||||||
|
foreach ($pts as $p) { $poly[] = $p[0] . ',' . $p[1]; }
|
||||||
|
$out .= '<polyline points="' . implode(' ', $poly) . '" fill="none" stroke="' . $color . '"'
|
||||||
|
. ' stroke-width="2" stroke-linejoin="round" stroke-linecap="round"/>';
|
||||||
|
// 每段转折点一个小点:有几个盘要点一眼看得出
|
||||||
|
foreach ($pts as $p) {
|
||||||
|
$out .= '<circle cx="' . $p[0] . '" cy="' . $p[1] . '" r="2.2" fill="#fff"'
|
||||||
|
. ' stroke="' . $color . '" stroke-width="1.5"/>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 首尾日期(中间太密,标了反糊)
|
||||||
|
$out .= '<text x="' . $padL . '" y="' . ($h - 5) . '" font-size="10" fill="currentColor"'
|
||||||
|
. ' fill-opacity=".55">' . e($dates[0]) . '</text>';
|
||||||
|
if ($n > 1) {
|
||||||
|
$out .= '<text x="' . ($padL + $iw) . '" y="' . ($h - 5) . '" font-size="10" text-anchor="end"'
|
||||||
|
. ' fill="currentColor" fill-opacity=".55">' . e($dates[$n - 1]) . '</text>';
|
||||||
|
}
|
||||||
|
// 右上角标一下最高/最低,否则不知道纵轴量级
|
||||||
|
$out .= '<text x="' . ($padL + $iw) . '" y="11" font-size="10" text-anchor="end"'
|
||||||
|
. ' fill="currentColor" fill-opacity=".6">' . e(\App\Ledger::money($max))
|
||||||
|
. ' / ' . e(\App\Ledger::money($min)) . '</text>';
|
||||||
|
$out .= '</svg>';
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|||||||
+286
-60
@@ -460,6 +460,38 @@ textarea.input { min-height: 76px; resize: vertical; }
|
|||||||
.detail-photo .name { font-size: 18px; font-weight: 600; margin: 14px 0 4px; }
|
.detail-photo .name { font-size: 18px; font-weight: 600; margin: 14px 0 4px; }
|
||||||
.detail-photo .sub { color: var(--muted); font-size: 13px; }
|
.detail-photo .sub { color: var(--muted); font-size: 13px; }
|
||||||
|
|
||||||
|
/* ---- v24 第 11 批 #3:详情页改「名片卡 + 全宽堆叠」----
|
||||||
|
⚠️ 旧的 .detail 是 260px + 1fr 两栏,头像卡比右栏矮得多 → 头像下面留一大片空白。
|
||||||
|
现在头像与基本信息并排成一张名片,其余内容全宽往下排。 */
|
||||||
|
.pcard {
|
||||||
|
display: flex; align-items: flex-start; gap: 18px;
|
||||||
|
background: var(--surface); border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius); box-shadow: var(--shadow);
|
||||||
|
padding: 18px 20px; margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
.pcard-avatar { flex: 0 0 auto; }
|
||||||
|
.pcard-avatar img {
|
||||||
|
width: 108px; height: 108px; object-fit: cover;
|
||||||
|
border-radius: 12px; display: block; background: var(--line-2);
|
||||||
|
}
|
||||||
|
.pcard-main { flex: 1 1 auto; min-width: 0; padding-top: 2px; }
|
||||||
|
.pcard-name {
|
||||||
|
display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap;
|
||||||
|
font-size: 21px; font-weight: 600; margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.pcard-real { font-size: 13px; font-weight: 400; color: var(--muted); }
|
||||||
|
.pcard-meta { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||||
|
.pcard-chip {
|
||||||
|
display: inline-block; padding: 3px 10px; border-radius: 999px;
|
||||||
|
background: var(--line-2); color: var(--ink); font-size: 12.5px;
|
||||||
|
}
|
||||||
|
.detail-stack { display: flex; flex-direction: column; gap: 18px; }
|
||||||
|
.detail-stack > div { min-width: 0; }
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.pcard { flex-direction: column; align-items: center; text-align: center; }
|
||||||
|
.pcard-name, .pcard-meta { justify-content: center; }
|
||||||
|
}
|
||||||
|
|
||||||
.kv { display: flex; flex-direction: column; }
|
.kv { display: flex; flex-direction: column; }
|
||||||
.kv-row {
|
.kv-row {
|
||||||
display: grid; grid-template-columns: 96px 1fr; gap: 12px;
|
display: grid; grid-template-columns: 96px 1fr; gap: 12px;
|
||||||
@@ -1013,16 +1045,58 @@ body[class] .semi-datepicker-day-inhover { background-color: color-mix(in srgb,
|
|||||||
「分组」「移出名册」两份表单全页只渲染一份(原来每卡各一份,1184 人 =
|
「分组」「移出名册」两份表单全页只渲染一份(原来每卡各一份,1184 人 =
|
||||||
2369 个 form / 27234 个 input,密码管理器逐个扫描把页面拖死)。
|
2369 个 form / 27234 个 input,密码管理器逐个扫描把页面拖死)。
|
||||||
这里给共享浮层一个定位容器,面板由 JS 贴合触发按钮摆放。 */
|
这里给共享浮层一个定位容器,面板由 JS 贴合触发按钮摆放。 */
|
||||||
.roster-menus { position: fixed; inset: 0; z-index: 60; pointer-events: none; }
|
/* ---- v24 第 11 批 #2:名册卡片的动作**居中模态框** ----
|
||||||
.roster-menus .rm-panel {
|
(第 10 批是"贴按钮的浮层",用户反馈分组"点第二次失效"且要求"像选观影人一样弹框",
|
||||||
position: fixed; pointer-events: auto;
|
于是改成遮罩 + 居中面板,与 _person_picker 同一形态。) */
|
||||||
|
.rm-modal { position: fixed; inset: 0; z-index: 60; display: flex;
|
||||||
|
align-items: center; justify-content: center; }
|
||||||
|
.rm-modal[hidden] { display: none; }
|
||||||
|
.rm-mask { position: absolute; inset: 0; background: rgba(47, 36, 30, .34); }
|
||||||
|
.rm-panel {
|
||||||
|
position: relative; z-index: 1;
|
||||||
background: var(--surface); border: 1px solid var(--line);
|
background: var(--surface); border: 1px solid var(--line);
|
||||||
border-radius: 10px; box-shadow: var(--shadow-l);
|
border-radius: 14px; box-shadow: var(--shadow-l);
|
||||||
padding: 8px 0; min-width: 200px;
|
width: min(360px, 92vw); max-height: min(78vh, 620px);
|
||||||
|
display: flex; flex-direction: column;
|
||||||
}
|
}
|
||||||
.roster-menus .rm-panel .person-move { padding: 0 10px; }
|
.rm-panel[hidden] { display: none; }
|
||||||
.roster-menus .rm-panel .person-move.is-self { padding: 0 12px; }
|
.rm-head {
|
||||||
.roster-menus[hidden] { display: none; }
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
padding: 12px 14px 10px; border-bottom: 1px solid var(--line-2);
|
||||||
|
}
|
||||||
|
.rm-head b { font-size: 14.5px; font-weight: 600; }
|
||||||
|
.rm-x {
|
||||||
|
margin-left: auto; border: 0; background: transparent; cursor: pointer;
|
||||||
|
font-size: 15px; line-height: 1; color: var(--muted); padding: 4px 6px; border-radius: 6px;
|
||||||
|
}
|
||||||
|
.rm-x:hover { background: var(--line-2); color: var(--ink); }
|
||||||
|
.rm-body { padding: 8px 6px; overflow-y: auto; }
|
||||||
|
.rm-foot {
|
||||||
|
display: flex; gap: 8px; justify-content: flex-end;
|
||||||
|
padding: 10px 14px; border-top: 1px solid var(--line-2);
|
||||||
|
}
|
||||||
|
.rm-warn {
|
||||||
|
margin: 0; padding: 10px 12px; font-size: 13px; line-height: 1.7;
|
||||||
|
background: #FDF2F0; border: 1px solid #F0D6D1; border-radius: 8px; color: #7A2A20;
|
||||||
|
}
|
||||||
|
.rm-modal .pg-menu { display: contents; }
|
||||||
|
.rm-modal .pd-form { display: contents; }
|
||||||
|
.rm-modal .person-move { display: block; padding: 6px 14px 10px; }
|
||||||
|
.rm-modal .person-move.is-kin { padding: 6px 14px 10px; }
|
||||||
|
|
||||||
|
/* ---- v24 第 11 批 #2:详情页底部「删除」= 大按钮(与「开通账号」同规格,红色底)---- */
|
||||||
|
.del-big { display: flex; flex-direction: column; gap: 12px; max-width: 420px; }
|
||||||
|
.del-big-tip { margin: 0; font-size: 13px; line-height: 1.75; color: var(--muted); }
|
||||||
|
.btn-block-lg {
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
width: 100%; padding: 12px 16px; font-size: 14.5px;
|
||||||
|
}
|
||||||
|
.del-big-confirm {
|
||||||
|
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
|
||||||
|
padding: 10px 12px; background: #FDF2F0;
|
||||||
|
border: 1px solid #F0D6D1; border-radius: 8px; font-size: 13px; color: #7A2A20;
|
||||||
|
}
|
||||||
|
.del-big-confirm[hidden] { display: none; }
|
||||||
.person-move { display: flex; gap: 6px; padding: 8px 12px; flex-wrap: wrap; }
|
.person-move { display: flex; gap: 6px; padding: 8px 12px; flex-wrap: wrap; }
|
||||||
.person-move .select { flex: 1 1 auto; min-width: 0; padding: 6px 8px; font-size: 13px; }
|
.person-move .select { flex: 1 1 auto; min-width: 0; padding: 6px 8px; font-size: 13px; }
|
||||||
.person-move .btn { flex: 0 0 auto; }
|
.person-move .btn { flex: 0 0 auto; }
|
||||||
@@ -1244,6 +1318,13 @@ body[class] .semi-datepicker-day-inhover { background-color: color-mix(in srgb,
|
|||||||
|
|
||||||
/* ---------------- 图片裁剪浮层 ---------------- */
|
/* ---------------- 图片裁剪浮层 ---------------- */
|
||||||
/* 覆盖层默认不占空间;打开时铺满整屏,把底下的页面锁住(裁剪时不该能点到别处) */
|
/* 覆盖层默认不占空间;打开时铺满整屏,把底下的页面锁住(裁剪时不该能点到别处) */
|
||||||
|
/* ---- 图片裁剪(v24 第 11 批 #5:改用 Cropper.js)----
|
||||||
|
用户的四条硬要求(原话见 assets/js/app.js 那段注释)在这里逐条落地:
|
||||||
|
① 弹框**不要有任何滚动条** → 面板 overflow:hidden;画布尺寸固定,由 Cropper 缩放图片去适应。
|
||||||
|
② 图片无论多大都要缩放到框内 → 交给 Cropper 的 viewMode:1(container 自适应)。
|
||||||
|
③ **不要缩放条** → 原来那根 .crop-zoom-row 整块删掉了,改滚轮缩放。
|
||||||
|
④ 裁剪框要比弹框小、**所有触点都露出来** → 画布四周留出 22px 内边距,
|
||||||
|
Cropper 的四个角手柄画成实心白点,永远落在面板可见区内。 */
|
||||||
.crop-overlay { display: none; }
|
.crop-overlay { display: none; }
|
||||||
.crop-overlay.is-open {
|
.crop-overlay.is-open {
|
||||||
display: flex; position: fixed; inset: 0; z-index: 100;
|
display: flex; position: fixed; inset: 0; z-index: 100;
|
||||||
@@ -1251,49 +1332,52 @@ body[class] .semi-datepicker-day-inhover { background-color: color-mix(in srgb,
|
|||||||
padding: 20px; background: rgba(44, 35, 29, .55);
|
padding: 20px; background: rgba(44, 35, 29, .55);
|
||||||
}
|
}
|
||||||
.crop-panel {
|
.crop-panel {
|
||||||
background: var(--surface); border-radius: var(--radius);
|
background: var(--surface); border-radius: 14px;
|
||||||
box-shadow: var(--shadow-l); padding: 16px;
|
box-shadow: var(--shadow-l); padding: 16px;
|
||||||
max-width: calc(100vw - 24px); max-height: calc(100vh - 24px); overflow: auto;
|
max-width: calc(100vw - 24px); max-height: calc(100vh - 24px);
|
||||||
|
/* ① 不许出滚动条:面板自己 overflow:hidden,画布尺寸按视口算死在下面 */
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex; flex-direction: column;
|
||||||
}
|
}
|
||||||
.crop-head {
|
.crop-head {
|
||||||
display: flex; align-items: baseline; gap: 8px;
|
display: flex; align-items: baseline; gap: 8px;
|
||||||
font-size: 15px; font-weight: 600; margin-bottom: 12px;
|
font-size: 15px; font-weight: 600; margin-bottom: 12px; flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
.crop-head .crop-shape { font-size: 12px; font-weight: 400; color: var(--muted); }
|
.crop-head .crop-shape { font-size: 12px; font-weight: 400; color: var(--muted); }
|
||||||
.crop-stage {
|
.crop-x {
|
||||||
position: relative; margin: 0 auto; overflow: auto;
|
margin-left: auto; align-self: center; border: 0; background: transparent;
|
||||||
background: #F1E7D6; border-radius: var(--radius-s);
|
font-size: 15px; line-height: 1; color: var(--muted);
|
||||||
touch-action: none; /* 手机上拖框时别把页面一起滚走 */
|
cursor: pointer; padding: 4px 6px; border-radius: 6px;
|
||||||
user-select: none;
|
|
||||||
}
|
}
|
||||||
/* 图片的宽高由 JS 按倍率写死(放大后比舞台大,靠舞台自身滚动看别处)——
|
.crop-x:hover { background: var(--line-2); color: var(--ink); }
|
||||||
所以这里**不能**写 width/height:100%,否则缩放就永远没效果。 */
|
/* 画布:**固定尺寸**(不是 max-height),这样 Cropper 一定会把图片缩进来,
|
||||||
.crop-img { display: block; max-width: none; }
|
而不会让容器去滚动。宽高都换算成"视口内还能放下的最大方块"。
|
||||||
/* 缩放条 —— 用户明确要求"裁剪的时候要能缩放" */
|
⚠️ 留 22px 内边距是给裁剪框四个角的手柄留位(要求④)。 */
|
||||||
.crop-zoom-row {
|
.crop-canvas {
|
||||||
display: flex; align-items: center; gap: 10px;
|
flex: 0 0 auto; position: relative;
|
||||||
margin-top: 12px; font-size: 13px; color: var(--muted);
|
width: min(560px, calc(100vw - 88px));
|
||||||
|
height: min(66vh, 460px);
|
||||||
|
border-radius: var(--radius-s); overflow: hidden;
|
||||||
|
background: #2C231D;
|
||||||
}
|
}
|
||||||
.crop-zoom { flex: 1; min-width: 120px; accent-color: var(--brand); }
|
.crop-canvas img { display: block; max-width: 100%; }
|
||||||
.crop-zoom-val { font-variant-numeric: tabular-nums; min-width: 42px; text-align: right; }
|
.crop-tip { color: var(--muted); font-size: 12px; margin: 12px 0 0; flex: 0 0 auto; }
|
||||||
/* 框外的部分压暗 —— 这是"你只留下框里这块"最直观的表达 */
|
.crop-actions { display: flex; gap: 10px; justify-content: flex-end; margin-top: 14px; flex: 0 0 auto; }
|
||||||
.crop-box {
|
|
||||||
position: absolute; cursor: move;
|
/* ④ 触点全露出:Cropper 默认的手柄太细、颜色偏灰,在深色画布上不好抓。
|
||||||
border: 2px solid #fff;
|
这里加粗、改成白底品牌边,并**保证它们不被画布裁掉**(Cropper 自己会留 margin)。 */
|
||||||
box-shadow: 0 0 0 9999px rgba(44, 35, 29, .5);
|
.cropper-view-box,
|
||||||
|
.cropper-face { border-radius: 2px; }
|
||||||
|
.cropper-line { background-color: rgba(255, 255, 255, .9); }
|
||||||
|
.cropper-point {
|
||||||
|
width: 12px; height: 12px; opacity: 1;
|
||||||
|
background-color: #fff; border: 2px solid var(--brand);
|
||||||
|
border-radius: 50%;
|
||||||
}
|
}
|
||||||
.crop-box::before {
|
.cropper-point.point-se { width: 14px; height: 14px; }
|
||||||
content: ''; position: absolute; inset: 0;
|
.cropper-bg { background-image: none; background-color: #2C231D; }
|
||||||
border: 1px dashed rgba(255, 255, 255, .75); pointer-events: none;
|
.cropper-modal { background-color: rgba(20, 14, 10, .55); }
|
||||||
}
|
|
||||||
.crop-handle {
|
|
||||||
position: absolute; right: -8px; bottom: -8px;
|
|
||||||
width: 20px; height: 20px; border-radius: 50%;
|
|
||||||
background: #fff; border: 2px solid var(--brand);
|
|
||||||
cursor: nwse-resize;
|
|
||||||
}
|
|
||||||
.crop-tip { color: var(--muted); font-size: 12px; margin: 12px 0 0; }
|
|
||||||
.crop-actions { display: flex; gap: 10px; justify-content: flex-end; margin-top: 14px; }
|
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
.order-move .btn { padding: 4px 10px; }
|
.order-move .btn { padding: 4px 10px; }
|
||||||
@@ -1595,6 +1679,62 @@ body[class] .semi-datepicker-day-inhover { background-color: color-mix(in srgb,
|
|||||||
.ck-head b { font-size: 14px; }
|
.ck-head b { font-size: 14px; }
|
||||||
.ck-svg { display: block; margin-top: 2px; }
|
.ck-svg { display: block; margin-top: 2px; }
|
||||||
|
|
||||||
|
/* ---- v24 第 11 批 #7:账目行的两个弹框(全页各一份)----
|
||||||
|
原来每行内联一套(form + 选人组件),账目一多就是几千个 form
|
||||||
|
—— 用户报的"账目页也卡、还是 Bitwarden 冲突"就是这个。
|
||||||
|
现在行上只有按钮,表单集中到 #ledPanels。 */
|
||||||
|
.led-modal {
|
||||||
|
position: fixed; inset: 0; z-index: 75;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
.led-modal[hidden] { display: none; }
|
||||||
|
.led-mask { position: absolute; inset: 0; background: rgba(47, 36, 30, .34); }
|
||||||
|
.led-panel-lg {
|
||||||
|
position: relative; z-index: 1;
|
||||||
|
background: var(--surface); border: 1px solid var(--line);
|
||||||
|
border-radius: 14px; box-shadow: var(--shadow-l);
|
||||||
|
width: min(460px, 92vw); max-height: min(86vh, 660px);
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
}
|
||||||
|
.led-panel-lg[hidden] { display: none; }
|
||||||
|
.led-ph {
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
padding: 12px 14px 10px; border-bottom: 1px solid var(--line-2);
|
||||||
|
}
|
||||||
|
.led-ph b { font-size: 14.5px; }
|
||||||
|
.led-x {
|
||||||
|
margin-left: auto; border: 0; background: transparent; cursor: pointer;
|
||||||
|
font-size: 15px; color: var(--muted); padding: 4px 6px; border-radius: 6px;
|
||||||
|
}
|
||||||
|
.led-x:hover { background: var(--line-2); color: var(--ink); }
|
||||||
|
.led-pb { padding: 12px 14px; overflow-y: auto; }
|
||||||
|
.led-pb .field { margin-bottom: 12px; }
|
||||||
|
.led-pb .field > label { display: block; font-size: 13px; color: var(--muted); margin-bottom: 5px; }
|
||||||
|
.led-pf {
|
||||||
|
display: flex; gap: 8px; justify-content: flex-end;
|
||||||
|
padding: 10px 14px; border-top: 1px solid var(--line-2);
|
||||||
|
}
|
||||||
|
/* 弹框里的选人组件别撑破面板 */
|
||||||
|
.led-pb .pp { max-width: none; }
|
||||||
|
.led-panel-lg .led-hist { margin-top: 4px; }
|
||||||
|
|
||||||
|
/* ---- v24 第 11 批 #7:图例(只显示"哪个颜色代表哪个账目")----
|
||||||
|
用户原话:「图下面的条目不用显示详情,只需要显示每个账目的颜色,
|
||||||
|
就是说哪个颜色代表哪个账目,就可以了。」
|
||||||
|
所以从"每账目一张小卡"改成一行色块图例。 */
|
||||||
|
.ck-legend {
|
||||||
|
display: flex; flex-wrap: wrap; gap: 6px 16px;
|
||||||
|
margin: 10px 2px 0; font-size: 13px; color: var(--ink);
|
||||||
|
}
|
||||||
|
.ck-lg-item { display: inline-flex; align-items: center; gap: 6px; }
|
||||||
|
.ck-lg-item[hidden] { display: none; }
|
||||||
|
.ck-lg-dot {
|
||||||
|
width: 11px; height: 11px; border-radius: 3px; flex: 0 0 auto;
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, .12);
|
||||||
|
}
|
||||||
|
/* 多线图:比单条小图高一点,才看得出差异 */
|
||||||
|
.ck-multi { margin-top: 4px; }
|
||||||
|
|
||||||
/* ---- v24 第 10 批 #3:总金额图在最前(跨整行),下面才是逐条账目 ---- */
|
/* ---- v24 第 10 批 #3:总金额图在最前(跨整行),下面才是逐条账目 ---- */
|
||||||
.ck-total { margin-bottom: 14px; }
|
.ck-total { margin-bottom: 14px; }
|
||||||
.ck-total .ck-head b { font-size: 15px; }
|
.ck-total .ck-head b { font-size: 15px; }
|
||||||
@@ -2428,6 +2568,92 @@ body[class] .semi-datepicker-day-inhover { background-color: color-mix(in srgb,
|
|||||||
.dash-when { flex: 0 0 96px; font-size: 12px; color: var(--muted); font-variant-numeric: tabular-nums; }
|
.dash-when { flex: 0 0 96px; font-size: 12px; color: var(--muted); font-variant-numeric: tabular-nums; }
|
||||||
.dash-what { flex: 1 1 auto; min-width: 0; font-size: 14px; }
|
.dash-what { flex: 1 1 auto; min-width: 0; font-size: 14px; }
|
||||||
.dash-tag { flex: 0 0 auto; font-size: 11px; color: var(--faint); }
|
.dash-tag { flex: 0 0 auto; font-size: 11px; color: var(--faint); }
|
||||||
|
/* ---- v24 第 11 批 #4:影院下拉(带色点)+ 新建影院弹框 ----
|
||||||
|
原生 <option> 画不了色块,所以自己做一个"按钮 + 弹出列表":
|
||||||
|
值仍写进隐藏的 input[name=cinema_id],服务端与既有测试都不用改。 */
|
||||||
|
.cinema-select-row { display: flex; align-items: flex-start; gap: 8px; }
|
||||||
|
.cs { position: relative; flex: 1 1 auto; min-width: 0; }
|
||||||
|
.cs-btn {
|
||||||
|
display: flex; align-items: center; gap: 8px; width: 100%;
|
||||||
|
padding: 10px 12px; font: inherit; font-size: 14px; color: var(--ink);
|
||||||
|
background: #fff; border: 1px solid var(--line); border-radius: var(--radius-s);
|
||||||
|
cursor: pointer; text-align: left;
|
||||||
|
}
|
||||||
|
.cs-btn:hover { border-color: var(--brand-2); }
|
||||||
|
.cs-btn:focus { outline: none; border-color: var(--brand); }
|
||||||
|
.cs-text { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.cs-caret { flex: 0 0 auto; color: var(--muted); font-size: 11px; }
|
||||||
|
.cs-dot {
|
||||||
|
flex: 0 0 auto; width: 12px; height: 12px; border-radius: 3px;
|
||||||
|
background: var(--line-2); box-shadow: inset 0 0 0 1px rgba(0, 0, 0, .12);
|
||||||
|
}
|
||||||
|
.cs-dot.is-none { background: transparent; box-shadow: inset 0 0 0 1px var(--line); }
|
||||||
|
.cs-menu {
|
||||||
|
position: absolute; left: 0; right: 0; top: calc(100% + 4px); z-index: 40;
|
||||||
|
background: var(--surface); border: 1px solid var(--line);
|
||||||
|
border-radius: 10px; box-shadow: var(--shadow-l);
|
||||||
|
padding: 4px; max-height: 280px; overflow-y: auto;
|
||||||
|
}
|
||||||
|
.cs-menu[hidden] { display: none; }
|
||||||
|
.cs-item {
|
||||||
|
display: flex; align-items: center; gap: 8px; width: 100%;
|
||||||
|
padding: 8px 10px; border: 0; border-radius: 7px; background: transparent;
|
||||||
|
font: inherit; font-size: 13.5px; color: var(--ink); cursor: pointer; text-align: left;
|
||||||
|
}
|
||||||
|
.cs-item:hover, .cs-item:focus { background: var(--line-2); outline: none; }
|
||||||
|
|
||||||
|
/* 新建影院:居中弹框(与名册的动作弹框同一形态,只是更宽) */
|
||||||
|
.cn-modal {
|
||||||
|
position: fixed; inset: 0; z-index: 70;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
.cn-modal[hidden] { display: none; }
|
||||||
|
.cn-mask { position: absolute; inset: 0; background: rgba(47, 36, 30, .34); }
|
||||||
|
.cn-panel {
|
||||||
|
position: relative; z-index: 1;
|
||||||
|
background: var(--surface); border: 1px solid var(--line);
|
||||||
|
border-radius: 14px; box-shadow: var(--shadow-l);
|
||||||
|
width: min(420px, 92vw); max-height: min(84vh, 620px);
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
}
|
||||||
|
.cn-head {
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
padding: 12px 14px 10px; border-bottom: 1px solid var(--line-2);
|
||||||
|
}
|
||||||
|
.cn-head b { font-size: 14.5px; }
|
||||||
|
.cn-x {
|
||||||
|
margin-left: auto; border: 0; background: transparent; cursor: pointer;
|
||||||
|
font-size: 15px; color: var(--muted); padding: 4px 6px; border-radius: 6px;
|
||||||
|
}
|
||||||
|
.cn-x:hover { background: var(--line-2); color: var(--ink); }
|
||||||
|
.cn-body { padding: 12px 14px; overflow-y: auto; }
|
||||||
|
.cn-body .field { margin-bottom: 12px; }
|
||||||
|
.cn-body .field > label { display: block; font-size: 13px; color: var(--muted); margin-bottom: 5px; }
|
||||||
|
.cn-foot {
|
||||||
|
display: flex; gap: 8px; justify-content: flex-end;
|
||||||
|
padding: 10px 14px; border-top: 1px solid var(--line-2);
|
||||||
|
}
|
||||||
|
.cn-presets { display: inline-flex; align-items: center; gap: 5px; flex-wrap: wrap; }
|
||||||
|
.cn-preset {
|
||||||
|
width: 20px; height: 20px; border-radius: 5px; border: 1px solid rgba(0, 0, 0, .12);
|
||||||
|
cursor: pointer; padding: 0;
|
||||||
|
}
|
||||||
|
.cn-preset:hover { transform: scale(1.08); }
|
||||||
|
|
||||||
|
/* ---- v24 第 11 批 #1:日历本改「实心色块」----
|
||||||
|
原来 .dash-tag 是 --faint 的 11px 灰字,压在宣纸底上几乎看不见(用户原话"看不清")。
|
||||||
|
现在:底色 = 该日历本的颜色(由模板内联),文字白色 —— 与左侧那个小圆点同一个颜色,
|
||||||
|
扫一眼就知道"这条是哪本日历的"。
|
||||||
|
⚠️ 颜色是用户自选的,浅色(如 #97A630)上白字会发虚 → 加一点文字阴影保底,
|
||||||
|
比"强制加深颜色"好:本子的颜色是用户定的,不该被我们改。 */
|
||||||
|
.dash-cal {
|
||||||
|
flex: 0 0 auto; align-self: center;
|
||||||
|
padding: 2px 9px; border-radius: 999px;
|
||||||
|
color: #fff; font-size: 11.5px; font-weight: 500; line-height: 1.7;
|
||||||
|
white-space: nowrap; max-width: 40%;
|
||||||
|
overflow: hidden; text-overflow: ellipsis;
|
||||||
|
text-shadow: 0 1px 1px rgba(0, 0, 0, .22);
|
||||||
|
}
|
||||||
@media (max-width: 620px) {
|
@media (max-width: 620px) {
|
||||||
.dash-when { flex: 0 0 auto; }
|
.dash-when { flex: 0 0 auto; }
|
||||||
}
|
}
|
||||||
@@ -2458,28 +2684,28 @@ body[class] .semi-datepicker-day-inhover { background-color: color-mix(in srgb,
|
|||||||
background: var(--brand); border-color: var(--brand); color: #fff; font-weight: 600;
|
background: var(--brand); border-color: var(--brand); color: #fff; font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- v24 第 10 批 #6:日程页头的「跳到某一天」按钮 ----
|
/* ---- 日程页头的「跳到某一天」(v24 第 11 批 #6 重做)----
|
||||||
外观与 .btn 一致(它是 <label> 包着一个原生 date 输入,所以自己画外壳)。
|
用户要求:「跳转日期那个地方,不要那么长,只需要和设置一样,一个按钮即可」,
|
||||||
原生日历指示器收掉:整个按钮都是可点的,多一个小图标反而抢视线。 */
|
且日期选择要用 Semi(与全站统一)。
|
||||||
|
所以现在是**一个方图标按钮**(与 ☰ 的 .btn-icon 同规格):
|
||||||
|
点开弹一个只含 Semi 日期选择器的轻浮层,选中即跳。 */
|
||||||
.cal-jump { display: inline-flex; margin: 0; }
|
.cal-jump { display: inline-flex; margin: 0; }
|
||||||
.cal-jump-btn {
|
.cal-jump-btn { display: inline-flex; align-items: center; justify-content: center; cursor: pointer; }
|
||||||
display: inline-flex; align-items: center; gap: 6px;
|
.cal-jump-host { position: fixed; inset: 0; z-index: 80; display: flex;
|
||||||
cursor: pointer; white-space: nowrap;
|
align-items: center; justify-content: center; }
|
||||||
|
.cal-jump-pop {
|
||||||
|
background: var(--surface); border: 1px solid var(--line);
|
||||||
|
border-radius: 14px; box-shadow: var(--shadow-l); padding: 10px 12px 12px;
|
||||||
}
|
}
|
||||||
.cal-jump-btn:hover { border-color: var(--brand-2); }
|
.cal-jump-pop-head {
|
||||||
.cal-jump-txt { font-size: 13px; }
|
display: flex; align-items: center; gap: 10px;
|
||||||
.cal-jump-date {
|
font-size: 13.5px; font-weight: 600; margin-bottom: 8px;
|
||||||
border: 0; background: transparent; font: inherit; font-size: 13px;
|
|
||||||
color: var(--ink); cursor: pointer; padding: 0; min-width: 118px;
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
}
|
||||||
.cal-jump-date::-webkit-calendar-picker-indicator { cursor: pointer; opacity: .55; }
|
.cal-jump-x {
|
||||||
.cal-jump-date:focus { outline: none; }
|
margin-left: auto; border: 0; background: transparent; cursor: pointer;
|
||||||
@media (max-width: 640px) {
|
font-size: 14px; color: var(--muted); padding: 3px 6px; border-radius: 6px;
|
||||||
/* 窄屏:只留图标 + 日期,把「跳到」两个字收掉(不然页头一排按钮换行) */
|
|
||||||
.cal-jump-txt { display: none; }
|
|
||||||
.cal-jump-date { min-width: 104px; }
|
|
||||||
}
|
}
|
||||||
|
.cal-jump-x:hover { background: var(--line-2); color: var(--ink); }
|
||||||
|
|
||||||
/* ==================== 周 / 天:按小时铺的时间网格 ==================== */
|
/* ==================== 周 / 天:按小时铺的时间网格 ==================== */
|
||||||
/* 结构:左边一条"小时刻度"沟槽(tg-gutter),右边 N 个日期列(tg-col)。
|
/* 结构:左边一条"小时刻度"沟槽(tg-gutter),右边 N 个日期列(tg-col)。
|
||||||
|
|||||||
+232
-259
@@ -26,308 +26,278 @@
|
|||||||
for (var i = 0; i < pickers.length; i++) { hook(pickers[i]); }
|
for (var i = 0; i < pickers.length; i++) { hook(pickers[i]); }
|
||||||
})();
|
})();
|
||||||
|
|
||||||
// ===== 图片裁剪 =====
|
// ===== 图片裁剪(v24 第 11 批 #5:改用 Cropper.js)=====
|
||||||
// 比例由**这个位置显示成什么形状**决定(头像 1:1、题图 16:9),
|
|
||||||
// 由服务端通过 data-crop-ratio 告诉这里 —— 前端不再自己定一个数字。
|
|
||||||
//
|
//
|
||||||
// 缩放(用户明确要求「裁剪图片的时候,需要可以进行缩放」):
|
// 用户原话:「所有上传图片带有裁剪功能的地方,都需要重做,现在仍然不好用。
|
||||||
// 舞台是个**固定大小的视口**(baseW×baseH),图片按 zoom 放大到 baseW*zoom,
|
// 用 Cropper.js 这个库,弹框不要有任何滚动条,图片就无论多大,
|
||||||
// 放不下就由舞台自己滚动。裁剪框的坐标一律相对**显示出来的图片**算,
|
// 就缩放到这个框内,也不要缩放条,鼠标滚轮滚动后,缩放大小,
|
||||||
// 最后除以 dispW/dispH 换成 0..1 —— 所以放大多少倍都不影响落库的那个矩形。
|
// 裁剪框,要比外面的弹框小,让所有的裁剪触点都露出来。」
|
||||||
// 框的最小边长固定 32 显示像素,于是放大之后能选中原图上更小的区域,
|
|
||||||
// 这正是"缩放"要解决的问题(合影里挑出某一个人的脸)。
|
|
||||||
//
|
//
|
||||||
// ⚠️ 这里只是「让用户挑区域」,真正裁切在服务端做(见 app/Upload.php):
|
// 逐条落地:
|
||||||
// 前端算出来的框只是**建议**,绕过前端也不会存进一张比例不对的图。
|
// · **弹框不许有滚动条** → 面板 overflow:hidden;图片用 `img { max-width:100% }`
|
||||||
// 所以确认时写回的是**归一化矩形**(相对原图的 0..1),不是像素。
|
// 交给 Cropper 的 viewMode:1 自动缩放进容器,而不是让容器去滚动。
|
||||||
|
// · **不要缩放条** → 去掉原来那条 <input type="range">,改滚轮:
|
||||||
|
// Cropper 的 wheelZoomRatio 直接支持(默认 0.1,这里调小一点更精细)。
|
||||||
|
// · **裁剪框比弹框小、触点全露出** → viewMode:1(框只能在画布内)
|
||||||
|
// + 面板四周留出 padding(CSS 的 .crop-panel),保证四角四个手柄永远在框内可见。
|
||||||
|
// · **不同形状** → 比例仍由服务端 data-crop-ratio 决定(头像 1:1、题图 16:9…),
|
||||||
|
// 前端不再自己定数字。可拖动的方向由 aspectRatio 允许的轴向自动决定。
|
||||||
|
//
|
||||||
|
// ⚠️ 提交契约**完全不变**:确认时仍写回 `avatar_crop` = 归一化 "x,y,w,h"(0..1)。
|
||||||
|
// Cropper.getData() 给的是**原图像素**,所以要除以 naturalWidth/naturalHeight ——
|
||||||
|
// 服务端(app/Upload.php)继续按归一化矩形裁,绕过前端也存不进比例不对的图。
|
||||||
|
// ⚠️ Cropper.js 是按需加载的:页面上真有 data-crop-ratio 的文件框才去拉这个库
|
||||||
|
// (与 Semi 那套同一思路:不能让没用到裁剪的页面白拉)。
|
||||||
(function () {
|
(function () {
|
||||||
var inputs = Array.prototype.slice.call(
|
var inputs = Array.prototype.slice.call(
|
||||||
document.querySelectorAll('input[type="file"][data-crop-ratio]'));
|
document.querySelectorAll('input[type="file"][data-crop-ratio]'));
|
||||||
if (!inputs.length) { return; }
|
if (!inputs.length) { return; }
|
||||||
|
|
||||||
var overlay = null, stage = null, imgEl = null, boxEl = null;
|
var meta = document.querySelector('meta[name="fb-cropper"]');
|
||||||
var st = null; // 当前编辑状态
|
if (!meta) { return; } // 页面没登记资源地址(不该发生,见 layout.php)
|
||||||
|
var conf;
|
||||||
|
try { conf = JSON.parse(meta.getAttribute('content')); } catch (e) { return; }
|
||||||
|
if (!conf || !conf.css || !conf.js) { return; }
|
||||||
|
|
||||||
|
var loading = null;
|
||||||
|
|
||||||
|
function loadCss(href) {
|
||||||
|
return new Promise(function (res) {
|
||||||
|
var l = document.createElement('link');
|
||||||
|
l.rel = 'stylesheet'; l.href = href;
|
||||||
|
l.onload = function () { res(true); };
|
||||||
|
l.onerror = function () { res(false); };
|
||||||
|
document.head.appendChild(l);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function loadJs(src) {
|
||||||
|
return new Promise(function (res, rej) {
|
||||||
|
var el = document.createElement('script');
|
||||||
|
el.src = src;
|
||||||
|
el.onload = function () { res(); };
|
||||||
|
el.onerror = function () { rej(new Error('加载失败: ' + src)); };
|
||||||
|
document.head.appendChild(el);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function ensureCropper() {
|
||||||
|
if (window.Cropper) { return Promise.resolve(); }
|
||||||
|
if (loading) { return loading; }
|
||||||
|
loading = loadCss(conf.css).then(function () { return loadJs(conf.js); });
|
||||||
|
return loading;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 解析 data-crop-ratio。
|
* 解析 data-crop-ratio;拿不到就退回 1(正方形)。
|
||||||
*
|
*
|
||||||
* ⚠️ 不能直接 parseFloat:'16:9' 会被读成 **16**(parseFloat 只吃开头那段数字),
|
* ⚠️ 必须支持 **"16:9" 这种带冒号的写法**:`parseFloat('16:9')` 会得到 **16**
|
||||||
* 于是裁剪框变成 16:1 的细长条,而钩子本身不报错、只是裁出来的图没法看。
|
* (它遇到 ':' 就停),于是"题图"槽位会裁出一个 16:1 的细长条 ——
|
||||||
* 头像的 '1:1' 恰好蒙对(=1),所以这个坑会潜伏到"启用题图"那天才炸。
|
* 页面不报错、图也存下去了,只有肉眼看才发现不对。
|
||||||
* 两种写法都支持:'16:9' / '16/9' 按分数算,纯数字按它本身算。
|
* (旧自研版正因为同一处栽过,交互测试里专门留了一条断言守它。)
|
||||||
*/
|
*/
|
||||||
function parseRatioAttr(v) {
|
function parseRatioAttr(v) {
|
||||||
var s = String(v == null ? '' : v).trim();
|
var t = String(v == null ? '' : v).trim();
|
||||||
var m = /^(\d+(?:\.\d+)?)\s*[:/]\s*(\d+(?:\.\d+)?)$/.exec(s);
|
if (t === '') { return 1; }
|
||||||
|
var m = t.match(/^(\d+(?:\.\d+)?)\s*[:/x×]\s*(\d+(?:\.\d+)?)$/);
|
||||||
if (m) {
|
if (m) {
|
||||||
var a = parseFloat(m[1]), b = parseFloat(m[2]);
|
var a = parseFloat(m[1]), b = parseFloat(m[2]);
|
||||||
return (isFinite(a) && isFinite(b) && b > 0 && a > 0) ? a / b : 1;
|
if (isFinite(a) && isFinite(b) && a > 0 && b > 0) { return a / b; }
|
||||||
}
|
}
|
||||||
var f = parseFloat(s);
|
var n = parseFloat(t);
|
||||||
return (isFinite(f) && f > 0) ? f : 1;
|
return (isFinite(n) && n > 0) ? n : 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- 弹框骨架(全页一个,复用)----
|
||||||
|
var overlay = null, panel = null, imgEl = null, hintEl = null, cropper = null;
|
||||||
|
var cur = null; // 当前编辑的 file input
|
||||||
|
var objUrl = '';
|
||||||
|
|
||||||
function build() {
|
function build() {
|
||||||
overlay = document.createElement('div');
|
overlay = document.createElement('div');
|
||||||
overlay.className = 'crop-overlay';
|
overlay.className = 'crop-overlay';
|
||||||
overlay.innerHTML =
|
overlay.innerHTML =
|
||||||
'<div class="crop-panel" role="dialog" aria-modal="true" aria-label="裁剪图片">' +
|
'<div class="crop-panel" role="dialog" aria-modal="true" aria-label="裁剪图片">' +
|
||||||
'<div class="crop-head"><b>裁剪图片</b><span class="crop-shape"></span></div>' +
|
'<div class="crop-head"><b>裁剪图片</b><span class="crop-shape"></span>' +
|
||||||
'<div class="crop-stage">' +
|
'<button type="button" class="crop-x" aria-label="关闭">✕</button></div>' +
|
||||||
'<img class="crop-img" alt="" draggable="false">' +
|
// ⚠️ 这里**不再有** stage / 缩放条 / 自画框:全部交给 Cropper.js。
|
||||||
'<div class="crop-box"><i class="crop-handle" aria-hidden="true"></i></div>' +
|
'<div class="crop-canvas"><img class="crop-img" alt=""></div>' +
|
||||||
'</div>' +
|
'<p class="crop-tip"></p>' +
|
||||||
'<div class="crop-zoom-row">' +
|
|
||||||
'<label for="cropZoom">缩放</label>' +
|
|
||||||
'<input type="range" class="crop-zoom" id="cropZoom" min="1" max="4" step="0.05" value="1">' +
|
|
||||||
'<span class="crop-zoom-val">1.0×</span>' +
|
|
||||||
'<button type="button" class="btn btn-ghost btn-sm" data-act="zoom-reset">还原</button>' +
|
|
||||||
'</div>' +
|
|
||||||
'<p class="crop-tip">拖动方框挪位置,拖右下角改大小。框里就是最终会保存的样子。</p>' +
|
|
||||||
'<div class="crop-actions">' +
|
'<div class="crop-actions">' +
|
||||||
'<button type="button" class="btn btn-ghost" data-act="cancel">取消</button>' +
|
'<button type="button" class="btn btn-ghost crop-cancel">取消</button>' +
|
||||||
'<button type="button" class="btn" data-act="ok">确定裁剪</button>' +
|
'<button type="button" class="btn crop-ok">就用这块</button>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'</div>';
|
'</div>';
|
||||||
document.body.appendChild(overlay);
|
document.body.appendChild(overlay);
|
||||||
stage = overlay.querySelector('.crop-stage');
|
panel = overlay.querySelector('.crop-panel');
|
||||||
imgEl = overlay.querySelector('.crop-img');
|
imgEl = overlay.querySelector('.crop-img');
|
||||||
boxEl = overlay.querySelector('.crop-box');
|
hintEl = overlay.querySelector('.crop-tip');
|
||||||
|
|
||||||
overlay.addEventListener('click', function (e) {
|
overlay.querySelector('.crop-x').addEventListener('click', function () { close(true); });
|
||||||
if (e.target === overlay) { close(false); return; }
|
overlay.querySelector('.crop-cancel').addEventListener('click', function () { close(true); });
|
||||||
var act = e.target.getAttribute && e.target.getAttribute('data-act');
|
overlay.addEventListener('click', function (e) { if (e.target === overlay) { close(true); } });
|
||||||
if (act === 'cancel') { close(false); }
|
|
||||||
if (act === 'ok') { close(true); }
|
|
||||||
if (act === 'zoom-reset') { setZoom(1); }
|
|
||||||
});
|
|
||||||
document.addEventListener('keydown', function (e) {
|
document.addEventListener('keydown', function (e) {
|
||||||
if (overlay && overlay.parentNode && e.key === 'Escape') { close(false); }
|
if (e.key === 'Escape' && overlay.classList.contains('is-open')) { close(true); }
|
||||||
});
|
});
|
||||||
|
overlay.querySelector('.crop-ok').addEventListener('click', confirm);
|
||||||
var zoomEl = overlay.querySelector('.crop-zoom');
|
|
||||||
zoomEl.addEventListener('input', function () {
|
|
||||||
var z = parseFloat(zoomEl.value);
|
|
||||||
if (isFinite(z) && z > 0) { setZoom(z); }
|
|
||||||
});
|
|
||||||
// Ctrl/⌘ + 滚轮 缩放;**普通滚轮不拦**,留给舞台自己滚动(放大之后要能平移)
|
|
||||||
stage.addEventListener('wheel', function (e) {
|
|
||||||
if (!st || !(e.ctrlKey || e.metaKey)) { return; }
|
|
||||||
e.preventDefault();
|
|
||||||
setZoom(st.zoom * (e.deltaY < 0 ? 1.1 : 1 / 1.1));
|
|
||||||
}, { passive: false });
|
|
||||||
|
|
||||||
bindDrag(overlay.querySelector('.crop-handle'), 'resize');
|
|
||||||
bindDrag(boxEl, 'move');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 让元素可拖动。pointer 事件同时覆盖鼠标与触摸,不用分别写两套。 */
|
|
||||||
function bindDrag(el, mode) {
|
|
||||||
el.addEventListener('pointerdown', function (e) {
|
|
||||||
if (!st || e.button > 0) { return; }
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
try { el.setPointerCapture(e.pointerId); } catch (err) { /* 老浏览器忽略 */ }
|
|
||||||
var x0 = e.clientX, y0 = e.clientY;
|
|
||||||
var b0 = { x: boxEl.offsetLeft, y: boxEl.offsetTop, w: boxEl.offsetWidth, h: boxEl.offsetHeight };
|
|
||||||
function onMove(ev) {
|
|
||||||
var dx = ev.clientX - x0, dy = ev.clientY - y0;
|
|
||||||
if (mode === 'move') {
|
|
||||||
place(b0.x + dx, b0.y + dy, b0.w, b0.h);
|
|
||||||
} else {
|
|
||||||
// 缩放时**锁定比例**:以宽为准推高,宽被夹住时高也跟着被夹住
|
|
||||||
var nw = b0.w + dx;
|
|
||||||
place(b0.x, b0.y, nw, nw / st.ratio);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function onUp() {
|
|
||||||
el.removeEventListener('pointermove', onMove);
|
|
||||||
el.removeEventListener('pointerup', onUp);
|
|
||||||
el.removeEventListener('pointercancel', onUp);
|
|
||||||
}
|
|
||||||
el.addEventListener('pointermove', onMove);
|
|
||||||
el.addEventListener('pointerup', onUp);
|
|
||||||
el.addEventListener('pointercancel', onUp);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
var MIN = 32; // 裁剪框最小边长(显示像素),太小了没法拖
|
|
||||||
var MAX_ZOOM = 4;
|
|
||||||
|
|
||||||
/** 把裁剪框放到指定位置,并夹在图片范围内 */
|
|
||||||
function place(x, y, w, h) {
|
|
||||||
w = Math.max(MIN, Math.min(w, st.dispW));
|
|
||||||
h = Math.max(MIN * st.ratio, Math.min(h, st.dispH));
|
|
||||||
// 比例锁定后再夹一次高度(宽高比可能因为上面的夹取而失配)
|
|
||||||
if (w / h > st.ratio) { w = h * st.ratio; } else { h = w / st.ratio; }
|
|
||||||
x = Math.max(0, Math.min(x, st.dispW - w));
|
|
||||||
y = Math.max(0, Math.min(y, st.dispH - h));
|
|
||||||
boxEl.style.left = x + 'px';
|
|
||||||
boxEl.style.top = y + 'px';
|
|
||||||
boxEl.style.width = w + 'px';
|
|
||||||
boxEl.style.height = h + 'px';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 改缩放。
|
* 关闭浮层。
|
||||||
*
|
*
|
||||||
* 关键:**框在屏幕上的位置和大小都不动**,动的是它底下那张图 ——
|
* @param {boolean} cancelled true = 用户**取消**(点 ✕ / 取消 / 遮罩 / Esc)。
|
||||||
* 放大=同一块区域看得更清楚;缩小=框进更多内容。
|
*
|
||||||
* (2026-09-30 用户反馈:之前的做法是"归一化区域不变",结果框跟着图一起变大,
|
* ⚠️ 取消时必须把 file input 清空:否则用户以为"没选图",实际上表单里
|
||||||
* 缩放几下框就大得找不到了。)
|
* 仍挂着那张**没裁过的原图**,一提交就传上去了(旧自研版有这一步,
|
||||||
* 落库的归一化矩形由 place() 夹取后自动重算 —— 缩放改变选中的区域,这是本意。
|
* 换成 Cropper.js 时被我漏掉,靠交互测试的"取消后文件框被清空"抓回来)。
|
||||||
|
* 确认(confirm)走的是另一条路 —— 那时文件正是用户要传的,不能清。
|
||||||
*/
|
*/
|
||||||
function setZoom(z) {
|
function close(cancelled) {
|
||||||
if (!st) { return; }
|
if (cropper) { cropper.destroy(); cropper = null; }
|
||||||
z = Math.max(1, Math.min(MAX_ZOOM, z));
|
if (overlay) { overlay.classList.remove('is-open'); }
|
||||||
var b = { x: boxEl.offsetLeft, y: boxEl.offsetTop, w: boxEl.offsetWidth, h: boxEl.offsetHeight };
|
if (objUrl) { URL.revokeObjectURL(objUrl); objUrl = ''; }
|
||||||
st.zoom = z;
|
if (cancelled && cur) {
|
||||||
st.dispW = Math.max(1, Math.round(st.baseW * z));
|
try { cur.value = ''; } catch (e) { /* 某些浏览器对 file.value 只读,忽略 */ }
|
||||||
st.dispH = Math.max(1, Math.round(st.baseH * z));
|
// 顺带把已经写进去的裁剪矩形也清掉,避免"上次的框"跟着下一次提交
|
||||||
|
var f0 = cur.form || document;
|
||||||
|
var h0 = f0.querySelector('input[type="hidden"][name$="_crop"]');
|
||||||
|
if (h0) { h0.value = ''; }
|
||||||
|
}
|
||||||
|
cur = null;
|
||||||
|
}
|
||||||
|
|
||||||
imgEl.style.width = st.dispW + 'px';
|
/**
|
||||||
imgEl.style.height = st.dispH + 'px';
|
* 对外暴露当前「图片显示尺寸 + 裁剪框」,**只给测试用**。
|
||||||
// 舞台是**固定大小的视口**(不跟着图片变大),放不下就滚动
|
*
|
||||||
stage.style.width = st.baseW + 'px';
|
* ⚠️ 存在的理由:验证"滚轮真的缩放了"不能只看"有没有 wheel 事件"——
|
||||||
stage.style.height = st.baseH + 'px';
|
* 那证明不了库真的动了图片。有了这个探针,测试可以断言
|
||||||
|
* "滚轮前后 getBoundingClientRect 的宽度确实变了"。
|
||||||
|
*/
|
||||||
|
window.__cropData = function () {
|
||||||
|
if (!imgEl) { return null; }
|
||||||
|
var d = cropper ? cropper.getData(true) : null;
|
||||||
|
var im = cropper ? cropper.getImageData() : null;
|
||||||
|
// ⚠️ 别用 imgEl.getBoundingClientRect() 量"显示尺寸":Cropper 把图片
|
||||||
|
// 放在 .cropper-canvas 里用 transform 缩放,img 自身的 rect 常常是 0。
|
||||||
|
// 真正代表"当前显示多大"的是 getImageData() 的 width/height(容器坐标)。
|
||||||
|
return {imgW: im ? Math.round(im.width) : 0,
|
||||||
|
imgH: im ? Math.round(im.height) : 0,
|
||||||
|
natW: im ? im.naturalWidth : 0,
|
||||||
|
crop: d ? {x: d.x, y: d.y, w: d.width, h: d.height} : null};
|
||||||
|
};
|
||||||
|
|
||||||
var zoomEl = overlay.querySelector('.crop-zoom');
|
function confirm() {
|
||||||
if (zoomEl) { zoomEl.value = String(z); }
|
if (!cropper || !cur) { close(); return; }
|
||||||
var valEl = overlay.querySelector('.crop-zoom-val');
|
var d = cropper.getData(true); // true = 取整
|
||||||
if (valEl) { valEl.textContent = z.toFixed(1) + '×'; }
|
var img = cropper.getImageData();
|
||||||
|
var nw = img.naturalWidth || 1;
|
||||||
// 框保持屏显大小;位置也保持(place 只做越界夹取,缩小到 1× 时兜底用)
|
var nh = img.naturalHeight || 1;
|
||||||
place(b.x, b.y, b.w, b.h);
|
// 归一化:服务端要的就是"相对原图的 0..1"
|
||||||
|
function r3(v) { return Math.round(v * 1000) / 1000; }
|
||||||
|
var x = r3(d.x / nw), y = r3(d.y / nh), w = r3(d.width / nw), h = r3(d.height / nh);
|
||||||
|
var box = cur.closest ? cur.closest('.avatar-slot, .field, form') : null;
|
||||||
|
var hidden = box ? box.querySelector('input[type="hidden"][name$="_crop"]') : null;
|
||||||
|
if (!hidden) {
|
||||||
|
// 兜底:找同一张表单里任何 *_crop 隐藏字段
|
||||||
|
var f = cur.form || document;
|
||||||
|
hidden = f.querySelector('input[type="hidden"][name$="_crop"]');
|
||||||
|
}
|
||||||
|
if (hidden) { hidden.value = [x, y, w, h].join(','); }
|
||||||
|
// 选完给个即时反馈:预览图要显示**框里那块**,而不是原图。
|
||||||
|
//
|
||||||
|
// ⚠️ 这里曾经只写了 `pv.src = objUrl`(原图)—— 于是"上车"的是整张原图,
|
||||||
|
// 用户看到预览没有任何变化,会以为没裁上。用户当初的原话是
|
||||||
|
// 「确认后把框里那块画成预览(所见即所得)」,这是必须保住的行为。
|
||||||
|
// 做法:用 canvas 按 Cropper 给的**原图像素矩形**重新画一遍。
|
||||||
|
var prevSel = cur.getAttribute('data-crop-preview');
|
||||||
|
if (prevSel) {
|
||||||
|
var pv = document.querySelector(prevSel);
|
||||||
|
if (pv) {
|
||||||
|
try {
|
||||||
|
var cw = Math.max(1, Math.round(d.width));
|
||||||
|
var ch = Math.max(1, Math.round(d.height));
|
||||||
|
var cv = document.createElement('canvas');
|
||||||
|
cv.width = cw; cv.height = ch;
|
||||||
|
cv.getContext('2d').drawImage(
|
||||||
|
imgEl,
|
||||||
|
Math.round(d.x), Math.round(d.y), cw, ch, // 源:框里那块(原图像素)
|
||||||
|
0, 0, cw, ch); // 目标:整张预览
|
||||||
|
pv.src = cv.toDataURL('image/png');
|
||||||
|
} catch (e) {
|
||||||
|
// 画布被污染 / 内存不足等极端情况 → 退回原图,别让预览空着
|
||||||
|
if (objUrl) { pv.src = objUrl; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
close();
|
||||||
}
|
}
|
||||||
|
|
||||||
function open(input, file) {
|
function open(input, file) {
|
||||||
|
cur = input;
|
||||||
if (!overlay) { build(); }
|
if (!overlay) { build(); }
|
||||||
var ratio = parseRatioAttr(input.getAttribute('data-crop-ratio'));
|
var ratio = parseRatioAttr(input.getAttribute('data-crop-ratio'));
|
||||||
var url = URL.createObjectURL(file);
|
var label = input.getAttribute('data-crop-label') || '图片';
|
||||||
var probe = new Image();
|
var hint = input.getAttribute('data-crop-hint') || '';
|
||||||
probe.onload = function () {
|
overlay.querySelector('.crop-shape').textContent =
|
||||||
// 图片按「等比缩放到可用区域」后显示;裁剪框的坐标都相对这块显示区域,
|
label + (ratio === 1 ? '(正方形)' : '(' + ratio.toFixed(2) + ':1)');
|
||||||
// 最后再除以它换成 0..1 —— 所以原图多大都不影响结果。
|
hintEl.textContent = hint !== '' ? hint
|
||||||
var availW = Math.min(window.innerWidth * 0.86, 620);
|
: '拖动图片挪位置,滚轮缩放;虚线框里就是最终保存的样子。';
|
||||||
var availH = Math.min(window.innerHeight * 0.52, 420);
|
|
||||||
var scale = Math.min(availW / probe.naturalWidth, availH / probe.naturalHeight, 1);
|
|
||||||
var baseW = Math.max(1, Math.round(probe.naturalWidth * scale));
|
|
||||||
var baseH = Math.max(1, Math.round(probe.naturalHeight * scale));
|
|
||||||
|
|
||||||
st = { input: input, ratio: ratio,
|
if (objUrl) { URL.revokeObjectURL(objUrl); }
|
||||||
baseW: baseW, baseH: baseH, zoom: 1,
|
objUrl = URL.createObjectURL(file);
|
||||||
dispW: baseW, dispH: baseH,
|
imgEl.src = objUrl;
|
||||||
naturalW: probe.naturalWidth, naturalH: probe.naturalHeight,
|
overlay.classList.add('is-open');
|
||||||
previewSrc: null, outW: parseInt(input.getAttribute('data-crop-w'), 10) || baseW,
|
|
||||||
outH: parseInt(input.getAttribute('data-crop-h'), 10) || baseH };
|
|
||||||
|
|
||||||
imgEl.src = url;
|
ensureCropper().then(function () {
|
||||||
imgEl.style.width = baseW + 'px';
|
if (cropper) { cropper.destroy(); cropper = null; }
|
||||||
imgEl.style.height = baseH + 'px';
|
cropper = new window.Cropper(imgEl, {
|
||||||
stage.style.width = baseW + 'px';
|
// ★ 比例由槽位决定(头像 1:1、题图 16:9…),Cropper 会据此锁定缩放方向。
|
||||||
stage.style.height = baseH + 'px';
|
// ⚠️ 少了它,框能自由拉伸,"题图"就会被裁成任意比例。
|
||||||
stage.scrollLeft = 0;
|
aspectRatio: ratio,
|
||||||
stage.scrollTop = 0;
|
// ① 框不能超出画布,且图片自动缩放铺进容器(**不产生滚动条**)
|
||||||
// ⚠️ 倍率每次打开都从 1× 开始:上一张图缩到 3× 不该带到这一张上,
|
viewMode: 1,
|
||||||
// 否则新图的框会莫名其妙是上一张的"放大版"。
|
// ② 图片可以比"容器"小或大,但始终被约束在画布内
|
||||||
var zoomEl = overlay.querySelector('.crop-zoom');
|
dragMode: 'move',
|
||||||
if (zoomEl) { zoomEl.value = '1'; }
|
// ③ 滚轮缩放(取代原来那根滑动条)
|
||||||
var valEl = overlay.querySelector('.crop-zoom-val');
|
zoomOnWheel: true,
|
||||||
if (valEl) { valEl.textContent = '1.0×'; }
|
wheelZoomRatio: 0.06,
|
||||||
|
// ④ 不许把图片缩到看不见 / 放到糊
|
||||||
var shape = overlay.querySelector('.crop-shape');
|
minContainerWidth: 200, minContainerHeight: 200,
|
||||||
shape.textContent = input.getAttribute('data-crop-label') || '';
|
minCropBoxWidth: 32, minCropBoxHeight: 32,
|
||||||
overlay.querySelector('.crop-tip').textContent =
|
// ⑤ 触点要**全露出来**:框四周始终留出可抓的边(见 CSS .cropper-* 手柄)
|
||||||
input.getAttribute('data-crop-hint') ||
|
autoCrop: true, autoCropArea: 0.86, center: true,
|
||||||
'拖动方框挪位置,拖右下角改大小;放不下就用「缩放」放大再看细节。框里就是最终会保存的样子。';
|
responsive: true, restore: false,
|
||||||
|
background: false,
|
||||||
// 默认框 = 图里最大的、符合目标比例的那块(居中)—— 什么都不动直接确定,
|
guides: true, highlight: false, movable: true, rotatable: false,
|
||||||
// 效果就等于老的「居中裁切」,所以默认值是安全的。
|
scalable: false, zoomable: true,
|
||||||
var w = baseW, h = w / ratio;
|
toggleDragModeOnDblclick: false,
|
||||||
if (h > baseH) { h = baseH; w = h * ratio; }
|
});
|
||||||
place((baseW - w) / 2, (baseH - h) / 2, w, h);
|
// 只给测试用:把实例露出去,便于用库 API 确定性地改变框尺寸
|
||||||
|
// (无头浏览器里 5×5 的触点很难稳定命中,靠鼠标拖会变成"测不稳")。
|
||||||
// 记住预览图原来的 src:取消时恢复,否则预览会停在"没裁过的图"上
|
window.__cropperInst = cropper;
|
||||||
var prev = preview(input);
|
}).catch(function (err) {
|
||||||
st.previewSrc = prev ? prev.src : null;
|
// 拉不到库 → 退回"不裁剪":清空隐藏字段(服务端会**居中裁切**),
|
||||||
st._url = url;
|
// 并明确告诉用户,而不是默默地不裁。
|
||||||
overlay.classList.add('is-open');
|
if (window.console) { console.warn('Cropper.js 未加载,改为中心裁切:', err && err.message); }
|
||||||
};
|
hintEl.textContent = '裁剪组件没加载出来 —— 保存时会自动居中裁切。';
|
||||||
probe.onerror = function () {
|
var f = input.form || document;
|
||||||
URL.revokeObjectURL(url);
|
var hid = f.querySelector('input[type="hidden"][name$="_crop"]');
|
||||||
alert('这张图片读不出来,换一张试试。');
|
|
||||||
input.value = '';
|
|
||||||
};
|
|
||||||
probe.src = url;
|
|
||||||
}
|
|
||||||
|
|
||||||
function preview(input) {
|
|
||||||
var sel = input.getAttribute('data-crop-preview');
|
|
||||||
return sel ? document.querySelector(sel) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function close(ok) {
|
|
||||||
if (!st) { return; }
|
|
||||||
if (ok) {
|
|
||||||
var nx = boxEl.offsetLeft / st.dispW;
|
|
||||||
var ny = boxEl.offsetTop / st.dispH;
|
|
||||||
var nw = boxEl.offsetWidth / st.dispW;
|
|
||||||
var nh = boxEl.offsetHeight / st.dispH;
|
|
||||||
// 写回归一化矩形 —— 服务端按它裁,与源图分辨率无关
|
|
||||||
var hidden = hiddenFor(st.input);
|
|
||||||
if (hidden) {
|
|
||||||
hidden.value = [nx, ny, nw, nh].map(function (v) {
|
|
||||||
return (Math.round(v * 1e6) / 1e6).toFixed(6);
|
|
||||||
}).join(',');
|
|
||||||
}
|
|
||||||
drawPreview();
|
|
||||||
} else {
|
|
||||||
st.input.value = ''; // 取消 = 不换图,别让它悄悄传上去被居中裁
|
|
||||||
var prev = preview(st.input);
|
|
||||||
if (prev && st.previewSrc !== null) { prev.src = st.previewSrc; }
|
|
||||||
var hid = hiddenFor(st.input);
|
|
||||||
if (hid) { hid.value = ''; }
|
if (hid) { hid.value = ''; }
|
||||||
}
|
|
||||||
overlay.classList.remove('is-open');
|
|
||||||
if (st._url) { URL.revokeObjectURL(st._url); }
|
|
||||||
st = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 按当前框把结果画出来当预览 —— 让用户看到"框里那块"长什么样 */
|
|
||||||
function drawPreview() {
|
|
||||||
var prev = preview(st.input);
|
|
||||||
if (!prev) { return; }
|
|
||||||
var canvas = document.createElement('canvas');
|
|
||||||
canvas.width = st.outW;
|
|
||||||
canvas.height = st.outH;
|
|
||||||
var g = canvas.getContext('2d');
|
|
||||||
var sx = boxEl.offsetLeft / st.dispW * st.naturalW;
|
|
||||||
var sy = boxEl.offsetTop / st.dispH * st.naturalH;
|
|
||||||
var sw = boxEl.offsetWidth / st.dispW * st.naturalW;
|
|
||||||
var sh = boxEl.offsetHeight / st.dispH * st.naturalH;
|
|
||||||
try {
|
|
||||||
g.drawImage(imgEl, sx, sy, sw, sh, 0, 0, st.outW, st.outH);
|
|
||||||
prev.src = canvas.toDataURL('image/jpeg', 0.9);
|
|
||||||
} catch (err) {
|
|
||||||
// 跨域图片会让 canvas 变"污染",这里都是本地 ObjectURL,正常不会发生
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 同表单里那个存裁剪矩形的隐藏字段 */
|
|
||||||
function hiddenFor(input) {
|
|
||||||
var form = input.form;
|
|
||||||
if (!form) { return null; }
|
|
||||||
return form.querySelector('input[name="' + input.name + '_crop"]');
|
|
||||||
}
|
|
||||||
|
|
||||||
inputs.forEach(function (input) {
|
|
||||||
input.addEventListener('change', function () {
|
|
||||||
var f = input.files && input.files[0];
|
|
||||||
if (!f) { return; }
|
|
||||||
if (!/^image\//.test(f.type)) { alert('请选择图片文件。'); input.value = ''; return; }
|
|
||||||
open(input, f);
|
|
||||||
});
|
});
|
||||||
});
|
}
|
||||||
|
|
||||||
|
for (var i = 0; i < inputs.length; i++) {
|
||||||
|
(function (input) {
|
||||||
|
input.addEventListener('change', function () {
|
||||||
|
var f = input.files && input.files[0];
|
||||||
|
if (!f) { return; }
|
||||||
|
if (!/^image\//.test(f.type)) {
|
||||||
|
alert('请选择图片文件。');
|
||||||
|
input.value = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
open(input, f);
|
||||||
|
});
|
||||||
|
})(inputs[i]);
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
// 头像选好后即时预览(仅用于**没走裁剪器**的文件框,避免和裁剪预览打架)
|
// 头像选好后即时预览(仅用于**没走裁剪器**的文件框,避免和裁剪预览打架)
|
||||||
@@ -966,8 +936,11 @@
|
|||||||
|
|
||||||
var host = document.createElement('span');
|
var host = document.createElement('span');
|
||||||
host.className = 'dt-semi dt-semi-range';
|
host.className = 'dt-semi dt-semi-range';
|
||||||
// 插到这一对控件的**前面**(原生那对随后藏起来)
|
// ⚠️ 插到**第一个原生控件之前**,不能插到 group 的最前面。
|
||||||
group.insertBefore(host, group.firstChild);
|
// 因为 group 就是那个 `.field`,`<label>` 也是它的子节点:
|
||||||
|
// 用 group.firstChild 会把控件塞到 label **上面**(第一版就是这么写的),
|
||||||
|
// 用户看到的现象正是"开始/结束时间这几个字跑到日期控件下面去了"。
|
||||||
|
group.insertBefore(host, a);
|
||||||
|
|
||||||
var fmt = 'yyyy-MM-dd HH:mm' + (secs ? ':ss' : '');
|
var fmt = 'yyyy-MM-dd HH:mm' + (secs ? ':ss' : '');
|
||||||
|
|
||||||
|
|||||||
+119
-14
@@ -79,21 +79,30 @@
|
|||||||
// `hy` + `yf` = `hyyf`,子序列 `yy` 正好跨过边界命中;
|
// `hy` + `yf` = `hyyf`,子序列 `yy` 正好跨过边界命中;
|
||||||
// 单独看 `hy` 与 `yf` 都不含 `yy`。逐字段判才是对的。
|
// 单独看 `hy` 与 `yf` 都不含 `yy`。逐字段判才是对的。
|
||||||
// 服务端(App\Pinyin::matches)本来就是逐字段判的,两边口径保持一致。
|
// 服务端(App\Pinyin::matches)本来就是逐字段判的,两边口径保持一致。
|
||||||
Array.prototype.forEach.call(list.querySelectorAll('.pp-item'), function (el) {
|
/**
|
||||||
var parts = [];
|
* 给一批 .pp-item 算 data-initials 与首字母徽标。
|
||||||
var n1 = el.getAttribute('data-name') || '';
|
* ⚠️ 抽成函数是因为**动态建出来的清单也要走同一套**(v24 第 11 批 #7 续:
|
||||||
var n2 = el.getAttribute('data-shown') || '';
|
* 候选人清单改成打开弹框时才建,建完必须补算首字母,
|
||||||
[n1, n2].forEach(function (s) {
|
* 否则"输入 yyq 找杨永全"这条既有功能会静默失效)。
|
||||||
if (!s) { return; }
|
*/
|
||||||
var ini = initialsOf ? initialsOf(s) : '';
|
function decorateItems(scope) {
|
||||||
if (ini && parts.indexOf(ini.toLowerCase()) < 0) { parts.push(ini.toLowerCase()); }
|
Array.prototype.forEach.call((scope || list).querySelectorAll('.pp-item'), function (el) {
|
||||||
|
var parts = [];
|
||||||
|
var n1 = el.getAttribute('data-name') || '';
|
||||||
|
var n2 = el.getAttribute('data-shown') || '';
|
||||||
|
[n1, n2].forEach(function (s) {
|
||||||
|
if (!s) { return; }
|
||||||
|
var ini = initialsOf ? initialsOf(s) : '';
|
||||||
|
if (ini && parts.indexOf(ini.toLowerCase()) < 0) { parts.push(ini.toLowerCase()); }
|
||||||
|
});
|
||||||
|
el.setAttribute('data-initials', parts.join(' '));
|
||||||
|
// 徽标只显示**姓名的**首字母(称呼那串太杂,显示出来反而更乱)
|
||||||
|
var badge = el.querySelector('.pp-initials');
|
||||||
|
var first = initialsOf ? initialsOf(n1) : '';
|
||||||
|
if (badge && first) { badge.textContent = first; badge.hidden = false; }
|
||||||
});
|
});
|
||||||
el.setAttribute('data-initials', parts.join(' '));
|
}
|
||||||
// 徽标只显示**姓名的**首字母(称呼那串太杂,显示出来反而更乱)
|
decorateItems();
|
||||||
var badge = el.querySelector('.pp-initials');
|
|
||||||
var first = initialsOf ? initialsOf(n1) : '';
|
|
||||||
if (badge && first) { badge.textContent = first; badge.hidden = false; }
|
|
||||||
});
|
|
||||||
|
|
||||||
function syncChips() {
|
function syncChips() {
|
||||||
chips.innerHTML = '';
|
chips.innerHTML = '';
|
||||||
@@ -159,8 +168,64 @@
|
|||||||
if (emptyTip) { emptyTip.hidden = shown > 0; }
|
if (emptyTip) { emptyTip.hidden = shown > 0; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按需把候选人清单建出来(v24 第 11 批 #7 续)。
|
||||||
|
*
|
||||||
|
* 为什么不在服务端首屏渲染:线上真实数据下每页 1~3 个选人组件 × 1184 人
|
||||||
|
* = 几千个 <label>+<input>(账目页因此 6.9 MB / 16788 个 input),
|
||||||
|
* 密码管理器逐个扫描 → 页面卡死。
|
||||||
|
* 现在只有在**用户真要看候选人**(打开弹框)的那一刻才建 DOM。
|
||||||
|
* 幂等:建过一次就打标记,反复开合不再重建。
|
||||||
|
*/
|
||||||
|
function buildList() {
|
||||||
|
if (list.getAttribute('data-pp-built') === '1') { return; }
|
||||||
|
var raw = list.getAttribute('data-pp-people') || '[]';
|
||||||
|
var people = [];
|
||||||
|
try { people = JSON.parse(raw) || []; } catch (e) { people = []; }
|
||||||
|
var selAttr = list.getAttribute('data-pp-selected') || '';
|
||||||
|
var selected = selAttr === '' ? [] : selAttr.split(',').map(function (v) { return String(v); });
|
||||||
|
var type = list.getAttribute('data-pp-type') || 'radio';
|
||||||
|
var fname = list.getAttribute('data-pp-name') || '';
|
||||||
|
|
||||||
|
var frag = document.createDocumentFragment();
|
||||||
|
people.forEach(function (p) {
|
||||||
|
var lab = document.createElement('label');
|
||||||
|
lab.className = 'pp-item';
|
||||||
|
lab.setAttribute('data-name', p.name || '');
|
||||||
|
lab.setAttribute('data-shown', p.shown || p.name || '');
|
||||||
|
var inp = document.createElement('input');
|
||||||
|
inp.type = type;
|
||||||
|
inp.name = fname;
|
||||||
|
inp.value = String(p.id);
|
||||||
|
if (selected.indexOf(String(p.id)) >= 0) { inp.checked = true; }
|
||||||
|
lab.appendChild(inp);
|
||||||
|
var av = document.createElement('img');
|
||||||
|
av.className = 'pp-av';
|
||||||
|
av.src = p.avatar || '';
|
||||||
|
av.alt = '';
|
||||||
|
av.loading = 'lazy';
|
||||||
|
lab.appendChild(av);
|
||||||
|
var nm = document.createElement('span');
|
||||||
|
nm.className = 'pp-name';
|
||||||
|
nm.textContent = p.shown || p.name || '';
|
||||||
|
lab.appendChild(nm);
|
||||||
|
var ini = document.createElement('span');
|
||||||
|
ini.className = 'pp-initials';
|
||||||
|
ini.hidden = true;
|
||||||
|
lab.appendChild(ini);
|
||||||
|
frag.appendChild(lab);
|
||||||
|
});
|
||||||
|
// 插在「不选」与 .pp-empty 之间(保持"不选"永远在最前)
|
||||||
|
var empty = list.querySelector('.pp-empty');
|
||||||
|
list.insertBefore(frag, empty || null);
|
||||||
|
list.setAttribute('data-pp-built', '1');
|
||||||
|
// 首字母要补算(组件的拼音筛选依赖它)—— 复用上面那套,口径只有一处
|
||||||
|
decorateItems(list);
|
||||||
|
}
|
||||||
|
|
||||||
function openModal() {
|
function openModal() {
|
||||||
modal.hidden = false;
|
modal.hidden = false;
|
||||||
|
buildList();
|
||||||
search.value = '';
|
search.value = '';
|
||||||
applyFilter();
|
applyFilter();
|
||||||
syncChips();
|
syncChips();
|
||||||
@@ -196,8 +261,48 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
syncChips();
|
syncChips();
|
||||||
|
// 把"重画 chips"暴露出去:外部程序化改了勾选(ppSetSelected)之后要能刷新展示
|
||||||
|
// ⚠️ 这里的作用域里变量名是 `root`(不是 box)—— 写成 box 会抛
|
||||||
|
// "box is not defined",而且是在**初始化末尾**抛,前面都正常,
|
||||||
|
// 只有后面的 ppSetSelected 才失效(e2e 抓不到,真浏览器才报)。
|
||||||
|
root.__ppSync = syncChips;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 对外接口:按**字段名**设置某个选人组件的选中项(单选)。
|
||||||
|
*
|
||||||
|
* 为什么需要它:共享弹框(名册的动作框、财务的修改框)打开时要把"这一行的人"
|
||||||
|
* 灌进组件里。组件是服务端渲染的、选中状态靠 `input:checked`,
|
||||||
|
* 从外部只知道字段名(person_id / attendees[]…),不知道 DOM 里的 id。
|
||||||
|
*
|
||||||
|
* 用法:window.ppSetSelected('person_id', 12) // 0 或不传 = 不指定
|
||||||
|
* ⚠️ 多选组件请传数组或逗号分隔的字符串。
|
||||||
|
*/
|
||||||
|
window.ppSetSelected = function (name, value) {
|
||||||
|
if (!name) { return false; }
|
||||||
|
var box = document.querySelector('.pp[data-pp-name="' + name + '"]');
|
||||||
|
if (!box) {
|
||||||
|
// 兼容:老渲染里没有 data-pp-name 时,退化成"找包含该 name 的 radio/checkbox"
|
||||||
|
var host = document.querySelector('.pp');
|
||||||
|
if (!host) { return false; }
|
||||||
|
box = host;
|
||||||
|
}
|
||||||
|
var want = [];
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
want = value.map(String);
|
||||||
|
} else if (typeof value === 'string' && value.indexOf(',') >= 0) {
|
||||||
|
want = value.split(',');
|
||||||
|
} else {
|
||||||
|
want = [String(value == null ? '' : value)];
|
||||||
|
}
|
||||||
|
Array.prototype.forEach.call(box.querySelectorAll('.pp-item input'), function (inp) {
|
||||||
|
inp.checked = want.indexOf(String(inp.value)) >= 0;
|
||||||
|
});
|
||||||
|
// 同步 chips(组件的选中态展示)
|
||||||
|
if (typeof box.__ppSync === 'function') { box.__ppSync(); }
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
function boot() {
|
function boot() {
|
||||||
Array.prototype.forEach.call(document.querySelectorAll('.pp'), init);
|
Array.prototype.forEach.call(document.querySelectorAll('.pp'), init);
|
||||||
}
|
}
|
||||||
|
|||||||
+9
@@ -0,0 +1,9 @@
|
|||||||
|
/*!
|
||||||
|
* Cropper.js v1.6.2
|
||||||
|
* https://fengyuanchen.github.io/cropperjs
|
||||||
|
*
|
||||||
|
* Copyright 2015-present Chen Fengyuan
|
||||||
|
* Released under the MIT license
|
||||||
|
*
|
||||||
|
* Date: 2024-04-21T07:43:02.731Z
|
||||||
|
*/.cropper-container{-webkit-touch-callout:none;direction:ltr;font-size:0;line-height:0;position:relative;-ms-touch-action:none;touch-action:none;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.cropper-container img{backface-visibility:hidden;display:block;height:100%;image-orientation:0deg;max-height:none!important;max-width:none!important;min-height:0!important;min-width:0!important;width:100%}.cropper-canvas,.cropper-crop-box,.cropper-drag-box,.cropper-modal,.cropper-wrap-box{bottom:0;left:0;position:absolute;right:0;top:0}.cropper-canvas,.cropper-wrap-box{overflow:hidden}.cropper-drag-box{background-color:#fff;opacity:0}.cropper-modal{background-color:#000;opacity:.5}.cropper-view-box{display:block;height:100%;outline:1px solid #39f;outline-color:rgba(51,153,255,.75);overflow:hidden;width:100%}.cropper-dashed{border:0 dashed #eee;display:block;opacity:.5;position:absolute}.cropper-dashed.dashed-h{border-bottom-width:1px;border-top-width:1px;height:33.33333%;left:0;top:33.33333%;width:100%}.cropper-dashed.dashed-v{border-left-width:1px;border-right-width:1px;height:100%;left:33.33333%;top:0;width:33.33333%}.cropper-center{display:block;height:0;left:50%;opacity:.75;position:absolute;top:50%;width:0}.cropper-center:after,.cropper-center:before{background-color:#eee;content:" ";display:block;position:absolute}.cropper-center:before{height:1px;left:-3px;top:0;width:7px}.cropper-center:after{height:7px;left:0;top:-3px;width:1px}.cropper-face,.cropper-line,.cropper-point{display:block;height:100%;opacity:.1;position:absolute;width:100%}.cropper-face{background-color:#fff;left:0;top:0}.cropper-line{background-color:#39f}.cropper-line.line-e{cursor:ew-resize;right:-3px;top:0;width:5px}.cropper-line.line-n{cursor:ns-resize;height:5px;left:0;top:-3px}.cropper-line.line-w{cursor:ew-resize;left:-3px;top:0;width:5px}.cropper-line.line-s{bottom:-3px;cursor:ns-resize;height:5px;left:0}.cropper-point{background-color:#39f;height:5px;opacity:.75;width:5px}.cropper-point.point-e{cursor:ew-resize;margin-top:-3px;right:-3px;top:50%}.cropper-point.point-n{cursor:ns-resize;left:50%;margin-left:-3px;top:-3px}.cropper-point.point-w{cursor:ew-resize;left:-3px;margin-top:-3px;top:50%}.cropper-point.point-s{bottom:-3px;cursor:s-resize;left:50%;margin-left:-3px}.cropper-point.point-ne{cursor:nesw-resize;right:-3px;top:-3px}.cropper-point.point-nw{cursor:nwse-resize;left:-3px;top:-3px}.cropper-point.point-sw{bottom:-3px;cursor:nesw-resize;left:-3px}.cropper-point.point-se{bottom:-3px;cursor:nwse-resize;height:20px;opacity:1;right:-3px;width:20px}@media (min-width:768px){.cropper-point.point-se{height:15px;width:15px}}@media (min-width:992px){.cropper-point.point-se{height:10px;width:10px}}@media (min-width:1200px){.cropper-point.point-se{height:5px;opacity:.75;width:5px}}.cropper-point.point-se:before{background-color:#39f;bottom:-50%;content:" ";display:block;height:200%;opacity:0;position:absolute;right:-50%;width:200%}.cropper-invisible{opacity:0}.cropper-bg{background-image:url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQAQMAAAAlPW0iAAAAA3NCSVQICAjb4U/gAAAABlBMVEXMzMz////TjRV2AAAACXBIWXMAAArrAAAK6wGCiw1aAAAAHHRFWHRTb2Z0d2FyZQBBZG9iZSBGaXJld29ya3MgQ1M26LyyjAAAABFJREFUCJlj+M/AgBVhF/0PAH6/D/HkDxOGAAAAAElFTkSuQmCC")}.cropper-hide{display:block;height:0;position:absolute;width:0}.cropper-hidden{display:none!important}.cropper-move{cursor:move}.cropper-crop{cursor:crosshair}.cropper-disabled .cropper-drag-box,.cropper-disabled .cropper-face,.cropper-disabled .cropper-line,.cropper-disabled .cropper-point{cursor:not-allowed}
|
||||||
Vendored
+10
File diff suppressed because one or more lines are too long
@@ -315,7 +315,13 @@ function month_grid_range(string $ym): array
|
|||||||
if ($firstTs === false) {
|
if ($firstTs === false) {
|
||||||
$firstTs = strtotime(date('Y-m') . '-01');
|
$firstTs = strtotime(date('Y-m') . '-01');
|
||||||
}
|
}
|
||||||
$w = (int)date('w', $firstTs); // 0=周日 … 6=周六
|
// ⚠️ v24 第 11 批 #6(用户原话「月视图,从周一开始。而不是周日。」):
|
||||||
|
// 这里原来是"周日开头"——$w 直接当回退天数(周日=0 → 不回退)。
|
||||||
|
// 现在改成**周一开头**:把周日(0)折算成 6、其余减 1,回退到本周一。
|
||||||
|
// ⚠️ 月网格与周视图必须用**同一套**起算规则,否则同一天在两张视图里
|
||||||
|
// 落在不同的列上,用户会以为日程"跳"了。
|
||||||
|
$wSun = (int)date('w', $firstTs); // 0=周日 … 6=周六
|
||||||
|
$w = ($wSun + 6) % 7; // 0=周一 … 6=周日
|
||||||
$start = strtotime('-' . $w . ' days', $firstTs);
|
$start = strtotime('-' . $w . ' days', $firstTs);
|
||||||
$cells = $w + (int)date('t', $firstTs);
|
$cells = $w + (int)date('t', $firstTs);
|
||||||
$rows = (int)ceil($cells / 7);
|
$rows = (int)ceil($cells / 7);
|
||||||
@@ -634,6 +640,14 @@ switch ($r) {
|
|||||||
$cals = Calendar::mine($familyId, $me);
|
$cals = Calendar::mine($familyId, $me);
|
||||||
$today = date('Y-m-d');
|
$today = date('Y-m-d');
|
||||||
$soon = Calendar::month($familyId, $cals, $me, $today, date('Y-m-d', strtotime('+7 days')));
|
$soon = Calendar::month($familyId, $cals, $me, $today, date('Y-m-d', strtotime('+7 days')));
|
||||||
|
// 用户 2026-10-04 第 11 批 #1:「首页,未来七天,不显示农历」。
|
||||||
|
// ⚠️ 农历是**每天一条**的派生条目(节假日也一样),七天就是七八行"廿一 / 廿二…",
|
||||||
|
// 把真正要看的日程全挤下去了 —— 它在月视图里当"日历刻度"很有用,
|
||||||
|
// 在首页这种"接下来要做什么"的清单里纯属噪音。
|
||||||
|
// 节假日保留("这周六放假"是有信息量的),农历整类去掉。
|
||||||
|
$soon = array_values(array_filter($soon, static function (array $e): bool {
|
||||||
|
return (string)($e['kind'] ?? '') !== 'lunar';
|
||||||
|
}));
|
||||||
// 进行中的任务 + 已经过期的待办(**只有这些才值得占首页**;
|
// 进行中的任务 + 已经过期的待办(**只有这些才值得占首页**;
|
||||||
// 没截止日期的待办放任务看板里看,堆到首页会变成一张长长的清单)
|
// 没截止日期的待办放任务看板里看,堆到首页会变成一张长长的清单)
|
||||||
$board = Task::board($familyId, $me);
|
$board = Task::board($familyId, $me);
|
||||||
@@ -2494,6 +2508,27 @@ switch ($r) {
|
|||||||
redirect('movies/cinemas', $id > 0 ? ['edit' => $id] : []);
|
redirect('movies/cinemas', $id > 0 ? ['edit' => $id] : []);
|
||||||
}
|
}
|
||||||
flash('ok', $id > 0 ? '影院信息已更新。' : '影院已添加。');
|
flash('ok', $id > 0 ? '影院信息已更新。' : '影院已添加。');
|
||||||
|
// 用户 2026-10-04 第 11 批 #4:「管理影院按钮,改为新建影院,点击后弹框,添加新影院,
|
||||||
|
// 不要跳转到管理影院的页面,操作被割裂了。」
|
||||||
|
// → 从**观影表单页**弹框新建时,表单带一个 back=movies/new,
|
||||||
|
// 存完回到那里(而不是甩到影院管理页),并且带上新影院的 id 让下拉直接选中它。
|
||||||
|
// ⚠️ 只认白名单里的两个值,绝不把 POST 的字符串直接当跳转地址(开放重定向)。
|
||||||
|
$back = (string)($_POST['back'] ?? '');
|
||||||
|
if ($back === 'movies/new') {
|
||||||
|
// 新建时要拿到刚插入的 id 才能"回来直接选中它"。
|
||||||
|
// ⚠️ Cinema::save() 返回的是**错误串**(空串=成功),不返回 id ——
|
||||||
|
// 所以这里按 (place, name) 回查一次;它是唯一键,查得到就是刚那条。
|
||||||
|
$newId = $id;
|
||||||
|
if ($newId <= 0) {
|
||||||
|
$st = Database::get()->prepare(
|
||||||
|
'SELECT id FROM cinemas WHERE family_id = ? AND place = ? AND name = ? LIMIT 1'
|
||||||
|
);
|
||||||
|
$st->execute([$familyId, trim((string)($_POST['place'] ?? '')),
|
||||||
|
trim((string)($_POST['name'] ?? ''))]);
|
||||||
|
$newId = (int)($st->fetchColumn() ?: 0);
|
||||||
|
}
|
||||||
|
redirect('movies/new', $newId > 0 ? ['cinema' => $newId] : []);
|
||||||
|
}
|
||||||
redirect('movies/cinemas');
|
redirect('movies/cinemas');
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
|||||||
@@ -47,7 +47,9 @@ $ppInputType = $ppIsMulti ? 'checkbox' : 'radio';
|
|||||||
// 直接 !$ppSelected 会误判成"选了人",于是「不选」那一项反而不勾。
|
// 直接 !$ppSelected 会误判成"选了人",于是「不选」那一项反而不勾。
|
||||||
$ppHasSel = count(array_filter($ppSelected)) > 0;
|
$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" id="<?= e($ppId) ?>" data-pp="<?= e($ppId) ?>"
|
||||||
|
data-pp-name="<?= e($ppName) ?>"
|
||||||
|
data-pp-mode="<?= e($ppIsMulti ? 'multi' : 'single') ?>">
|
||||||
<div class="pp-chips" id="<?= e($ppId) ?>-chips">
|
<div class="pp-chips" id="<?= e($ppId) ?>-chips">
|
||||||
<?php /* ⚠️ 循环变量一律用 $ppRow,绝不叫 $p:宿主模板里 $p 常常是别的东西
|
<?php /* ⚠️ 循环变量一律用 $ppRow,绝不叫 $p:宿主模板里 $p 常常是别的东西
|
||||||
(高级搜索页的 $p 是"查询条件数组"),被这个片段一覆盖,
|
(高级搜索页的 $p 是"查询条件数组"),被这个片段一覆盖,
|
||||||
@@ -79,27 +81,59 @@ $ppHasSel = count(array_filter($ppSelected)) > 0;
|
|||||||
<input class="input pp-search" id="<?= e($ppId) ?>-search" type="text"
|
<input class="input pp-search" id="<?= e($ppId) ?>-search" type="text"
|
||||||
placeholder="输入名字或拼音首字母,如 yyq 找「杨永全」" autocomplete="off">
|
placeholder="输入名字或拼音首字母,如 yyq 找「杨永全」" autocomplete="off">
|
||||||
</div>
|
</div>
|
||||||
<div class="pp-list" id="<?= e($ppId) ?>-list">
|
<?php
|
||||||
|
/* ⚠️ v24 第 11 批 #7 续:候选人清单**首屏不建 DOM**。
|
||||||
|
线上真实数据下,每页 1~3 个选人组件 × 1184 人 = 几千个 <label>+<input>,
|
||||||
|
账目页因此到 6.9 MB / 16788 个 input,密码管理器逐个扫描把页面拖死。
|
||||||
|
现在按 data-pp-people(JSON)在**打开弹框时**建清单;
|
||||||
|
无 JS 时由下面的 <noscript> 兜底(启用脚本时它只是纯文本,不产生节点)。 */
|
||||||
|
$ppJson = [];
|
||||||
|
foreach ($ppPeople as $ppRow) {
|
||||||
|
$ppJson[] = [
|
||||||
|
'id' => (int)$ppRow['id'],
|
||||||
|
'name' => (string)$ppRow['name'],
|
||||||
|
'shown' => person_name($ppRow),
|
||||||
|
'avatar' => avatar_url($ppRow['avatar'] ?? null),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
unset($ppRow);
|
||||||
|
?>
|
||||||
|
<div class="pp-list" id="<?= e($ppId) ?>-list"
|
||||||
|
data-pp-people="<?= e(json_encode($ppJson, JSON_UNESCAPED_UNICODE | JSON_HEX_APOS | JSON_HEX_QUOT)) ?>"
|
||||||
|
data-pp-name="<?= e($ppName) ?>"
|
||||||
|
data-pp-type="<?= e($ppInputType) ?>"
|
||||||
|
data-pp-selected="<?= e(implode(',', array_map('intval', $ppSelected))) ?>"
|
||||||
|
data-pp-allow-empty="<?= (!$ppIsMulti && $ppAllowEmpty) ? '1' : '0' ?>">
|
||||||
|
<?php /* 「不选」很轻(1 个 input)且是"默认不指定"语义的载体 → 留在服务端渲染。
|
||||||
|
⚠️ 它必须留在**普通容器**里(不是 noscript),否则 ppSetSelected 找不到它。 */ ?>
|
||||||
<?php if (!$ppIsMulti && $ppAllowEmpty): ?>
|
<?php if (!$ppIsMulti && $ppAllowEmpty): ?>
|
||||||
<label class="pp-item pp-item-none" data-name="不选" data-shown="不选">
|
<label class="pp-item pp-item-none" data-name="不选" data-shown="不选">
|
||||||
<input type="radio" name="<?= e($ppName) ?>" value="0" <?= $ppHasSel ? '' : 'checked' ?>>
|
<input type="radio" name="<?= e($ppName) ?>" value="0" <?= $ppHasSel ? '' : 'checked' ?>>
|
||||||
<span class="pp-name">不选</span>
|
<span class="pp-name">不选</span>
|
||||||
</label>
|
</label>
|
||||||
<?php endif; ?>
|
<?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 class="pp-empty" hidden>没有匹配的人。</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<?php /* 无 JS 兜底:启用脚本时这段只是**纯文本**(HTML5 规定),
|
||||||
|
不会变成 DOM 节点、也就不会造成上面的膨胀;
|
||||||
|
禁用脚本时才渲染出来,选人照样能用。 */ ?>
|
||||||
|
<noscript>
|
||||||
|
<div class="pp-list pp-list-noscript">
|
||||||
|
<?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>
|
||||||
|
</noscript>
|
||||||
</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>
|
<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>
|
||||||
|
|||||||
@@ -77,20 +77,29 @@ $headText = $view === 'month' ? date('Y 年 n 月', $ts)
|
|||||||
⚠️ 用原生 <input type="date"> 而不是再引一个弹层:它的原生日历足够用,
|
⚠️ 用原生 <input type="date"> 而不是再引一个弹层:它的原生日历足够用,
|
||||||
而且**选中就有值**(change 事件)→ 直接跳转,不用再点一次"确定"。
|
而且**选中就有值**(change 事件)→ 直接跳转,不用再点一次"确定"。
|
||||||
桌面/手机/无 JS 三种情况下的行为都是可预期的。 */ ?>
|
桌面/手机/无 JS 三种情况下的行为都是可预期的。 */ ?>
|
||||||
|
<?php /* 用户 2026-10-04 第 11 批 #6 原话(两句):
|
||||||
|
· 「跳转日期那个日期选择框,用我之前给你的那个,保持所有样式和格式的统一。」
|
||||||
|
· 「跳转日期那个地方,不要那么长,只需要和设置一样,一个按钮即可。」
|
||||||
|
→ 从"一个长条(图标 + 文字 + 原生日期框)"改成**与设置按钮同规格的单个图标按钮**
|
||||||
|
(btn-icon,与 ☰ 同宽同高),点开弹出 Semi 日期选择器(与全站日期控件同一套样式),
|
||||||
|
选中即跳到那天的日视图。
|
||||||
|
原生 input[type=date] 仍然保留(noscript 里)——"没 JS 也能跳日期"这件事没丢。 */ ?>
|
||||||
<form class="cal-jump" method="get" action="index.php">
|
<form class="cal-jump" method="get" action="index.php">
|
||||||
<input type="hidden" name="r" value="calendar">
|
<input type="hidden" name="r" value="calendar">
|
||||||
<input type="hidden" name="view" value="day">
|
<input type="hidden" name="view" value="day">
|
||||||
<label class="btn btn-ghost cal-jump-btn" title="跳到某一天">
|
<input type="hidden" name="d" id="calJumpD" value="<?= e($anchor) ?>">
|
||||||
<svg viewBox="0 0 16 16" width="17" height="17" aria-hidden="true" fill="none"
|
<button type="button" class="btn btn-icon cal-jump-btn" id="calJumpBtn"
|
||||||
stroke="currentColor" stroke-width="1.5" stroke-linecap="round">
|
title="跳到某一天" aria-label="跳到某一天">
|
||||||
|
<svg viewBox="0 0 16 16" width="18" height="18" aria-hidden="true" fill="none"
|
||||||
|
stroke="currentColor" stroke-width="1.7" stroke-linecap="round">
|
||||||
<rect x="2" y="3.2" width="12" height="10.8" rx="1.6"/>
|
<rect x="2" y="3.2" width="12" height="10.8" rx="1.6"/>
|
||||||
<path d="M2 6.6h12M5.2 1.8v2.6M10.8 1.8v2.6"/>
|
<path d="M2 6.6h12M5.2 1.8v2.6M10.8 1.8v2.6"/>
|
||||||
</svg>
|
</svg>
|
||||||
<span class="cal-jump-txt">跳到</span>
|
</button>
|
||||||
<input class="cal-jump-date" type="date" name="d" value="<?= e($anchor) ?>"
|
<noscript>
|
||||||
aria-label="选择要跳转的日期">
|
<input class="input" type="date" name="d" value="<?= e($anchor) ?>" aria-label="选择日期">
|
||||||
</label>
|
<button class="btn btn-sm" type="submit">跳转</button>
|
||||||
<noscript><button class="btn btn-ghost btn-sm" type="submit">跳转</button></noscript>
|
</noscript>
|
||||||
</form>
|
</form>
|
||||||
<?php /* 用户第 4、5 条:日历本 / 颜色 / 导入 / 默认视图 全收进这个 ☰ ——
|
<?php /* 用户第 4、5 条:日历本 / 颜色 / 导入 / 默认视图 全收进这个 ☰ ——
|
||||||
它顶替了原来左边那一栏,所以排在**最右**(靠 .page-head .actions 的顺序)。 */ ?>
|
它顶替了原来左边那一栏,所以排在**最右**(靠 .page-head .actions 的顺序)。 */ ?>
|
||||||
@@ -151,6 +160,8 @@ $headText = $view === 'month' ? date('Y 年 n 月', $ts)
|
|||||||
<div class="tg-gutter"></div>
|
<div class="tg-gutter"></div>
|
||||||
<?php foreach (($cols ?? []) as $c): ?>
|
<?php foreach (($cols ?? []) as $c): ?>
|
||||||
<div class="tg-dayname<?= $c['is_today'] ? ' is-today' : '' ?>">
|
<div class="tg-dayname<?= $c['is_today'] ? ' is-today' : '' ?>">
|
||||||
|
<?php /* $c['w'] 是 date('w')(0=周日),所以这张索引表保持"日"开头——
|
||||||
|
它只是把数字换成汉字,与列的排列顺序无关。 */ ?>
|
||||||
<span class="tg-wd"><?= e(['日','一','二','三','四','五','六'][$c['w']]) ?></span>
|
<span class="tg-wd"><?= e(['日','一','二','三','四','五','六'][$c['w']]) ?></span>
|
||||||
<span class="tg-dd"><?= (int)$c['day'] ?></span>
|
<span class="tg-dd"><?= (int)$c['day'] ?></span>
|
||||||
</div>
|
</div>
|
||||||
@@ -201,7 +212,9 @@ $headText = $view === 'month' ? date('Y 年 n 月', $ts)
|
|||||||
<?php else: ?>
|
<?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 /* ⚠️ 用户第 11 批 #6:「月视图,从周一开始。而不是周日。」
|
||||||
|
表头顺序必须与 month_grid_range() 的起算规则一致(那里已改成周一)。 */ ?>
|
||||||
|
<?php foreach (['一', '二', '三', '四', '五', '六', '日'] as $wd): ?>
|
||||||
<div class="cal-wd"><?= e($wd) ?></div>
|
<div class="cal-wd"><?= e($wd) ?></div>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</div>
|
</div>
|
||||||
@@ -330,25 +343,69 @@ $headText = $view === 'month' ? date('Y 年 n 月', $ts)
|
|||||||
/* 「全天」勾上藏掉时刻那一段的逻辑在 _event_fields.php 里(两个页面共用一份) */
|
/* 「全天」勾上藏掉时刻那一段的逻辑在 _event_fields.php 里(两个页面共用一份) */
|
||||||
})();
|
})();
|
||||||
|
|
||||||
/* ---- 用户第 6 条:「跳到某一天」—— 选中即跳,不用再点确定 ---- */
|
/* ---- 用户第 6 条:「跳到某一天」—— 单个图标按钮 + Semi 日期选择器 ---- */
|
||||||
(function () {
|
(function () {
|
||||||
var box = document.querySelector('.cal-jump');
|
var form = document.querySelector('.cal-jump');
|
||||||
if (!box) { return; }
|
var btn = document.getElementById('calJumpBtn');
|
||||||
var inp = box.querySelector('.cal-jump-date');
|
var hid = document.getElementById('calJumpD');
|
||||||
if (!inp) { return; }
|
if (!form || !btn || !hid) { return; }
|
||||||
inp.addEventListener('change', function () {
|
|
||||||
if (inp.value) { box.submit(); }
|
function jump(v) {
|
||||||
});
|
if (!v) { return; }
|
||||||
// 点标签区域也应能弹出原生日历(有些浏览器只认输入框本身)
|
hid.value = v;
|
||||||
var lab = box.querySelector('.cal-jump-btn');
|
form.submit();
|
||||||
if (lab) {
|
|
||||||
lab.addEventListener('click', function (e) {
|
|
||||||
if (e.target === inp) { return; }
|
|
||||||
if (typeof inp.showPicker === 'function') {
|
|
||||||
try { inp.showPicker(); } catch (err) { /* 用户手势之外会被拒,忽略 */ }
|
|
||||||
}
|
|
||||||
inp.focus();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
var meta = document.querySelector('meta[name="fb-semi"]');
|
||||||
|
var conf = null;
|
||||||
|
try { conf = meta ? JSON.parse(meta.getAttribute('content')) : null; } catch (e) { conf = null; }
|
||||||
|
|
||||||
|
function fallback() {
|
||||||
|
// Semi 没就绪 → 用一个临时原生日期框(无 JS 时的兜底与这里同一行为)
|
||||||
|
var t = document.createElement('input');
|
||||||
|
t.type = 'date';
|
||||||
|
t.value = hid.value;
|
||||||
|
t.style.position = 'fixed';
|
||||||
|
t.style.left = '-9999px';
|
||||||
|
document.body.appendChild(t);
|
||||||
|
t.addEventListener('change', function () { jump(t.value); });
|
||||||
|
if (typeof t.showPicker === 'function') { try { t.showPicker(); } catch (e2) {} }
|
||||||
|
else { t.focus(); t.click(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!window.SemiUI || !window.SemiUI.DatePicker || !window.ReactDOM) { fallback(); return; }
|
||||||
|
|
||||||
|
// 弹一个**只有一个日期选择器**的轻浮层(与全站日期控件同一套样式)
|
||||||
|
var host = document.getElementById('calJumpHost');
|
||||||
|
if (!host) {
|
||||||
|
host = document.createElement('div');
|
||||||
|
host.id = 'calJumpHost';
|
||||||
|
host.className = 'cal-jump-host';
|
||||||
|
document.body.appendChild(host);
|
||||||
|
}
|
||||||
|
var wrap = document.createElement('div');
|
||||||
|
wrap.className = 'cal-jump-pop';
|
||||||
|
wrap.innerHTML = '<div class="cal-jump-pop-head">跳到哪一天?' +
|
||||||
|
'<button type="button" class="cal-jump-x" aria-label="关闭">✕</button></div>';
|
||||||
|
var inner = document.createElement('div');
|
||||||
|
wrap.appendChild(inner);
|
||||||
|
host.innerHTML = '';
|
||||||
|
host.appendChild(wrap);
|
||||||
|
|
||||||
|
ReactDOM.createRoot(inner).render(React.createElement(SemiUI.DatePicker, {
|
||||||
|
type: 'date',
|
||||||
|
format: 'yyyy-MM-dd',
|
||||||
|
defaultValue: hid.value ? new Date(hid.value + 'T00:00:00') : new Date(),
|
||||||
|
open: true,
|
||||||
|
// 选中即跳,不用再点"确定"
|
||||||
|
onChange: function (d, ds) {
|
||||||
|
if (ds) { jump(ds); }
|
||||||
|
},
|
||||||
|
onOpenChange: function (o) { if (!o) { host.innerHTML = ''; } }
|
||||||
|
}));
|
||||||
|
|
||||||
|
wrap.querySelector('.cal-jump-x').addEventListener('click', function () { host.innerHTML = ''; });
|
||||||
|
});
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
+6
-1
@@ -154,7 +154,12 @@ $homeMods = array_values(array_filter(
|
|||||||
<?php if (($ev['time'] ?? '') !== ''): ?><b><?= e((string)$ev['time']) ?></b><?php endif; ?>
|
<?php if (($ev['time'] ?? '') !== ''): ?><b><?= e((string)$ev['time']) ?></b><?php endif; ?>
|
||||||
</span>
|
</span>
|
||||||
<a class="dash-what" href="<?= e((string)$ev['href']) ?>"><?= e((string)$ev['title']) ?></a>
|
<a class="dash-what" href="<?= e((string)$ev['href']) ?>"><?= e((string)$ev['title']) ?></a>
|
||||||
<span class="dash-tag"><?= e((string)$ev['calname']) ?></span>
|
<?php /* 用户 2026-10-04 第 11 批 #1:「右侧的日历本,现在看不清,
|
||||||
|
应该用一个色块,颜色就用日历的颜色,文字是日历本的文字,用白色。」
|
||||||
|
原来是 .dash-tag(--faint 灰色 11px 小字),在浅色纸上几乎看不见 ——
|
||||||
|
现在做成实心色块:底色 = 这本日历的颜色,字 = 白,一眼能对上左侧那个小圆点。 */ ?>
|
||||||
|
<span class="dash-cal"
|
||||||
|
style="background: <?= e((string)$ev['color']) ?>;"><?= e((string)$ev['calname']) ?></span>
|
||||||
</li>
|
</li>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -48,6 +48,13 @@ $isLoggedIn = \App\Auth::check();
|
|||||||
'dom' => asset('/assets/vendor/semi/react-dom.min.js'),
|
'dom' => asset('/assets/vendor/semi/react-dom.min.js'),
|
||||||
'ui' => asset('/assets/vendor/semi/semi-ui.min.js'),
|
'ui' => asset('/assets/vendor/semi/semi-ui.min.js'),
|
||||||
], JSON_UNESCAPED_UNICODE | JSON_HEX_APOS | JSON_HEX_QUOT)) ?>">
|
], JSON_UNESCAPED_UNICODE | JSON_HEX_APOS | JSON_HEX_QUOT)) ?>">
|
||||||
|
<?php /* v24 第 11 批 #5:Cropper.js(裁剪)也是**按需加载** ——
|
||||||
|
只有页面上真有 input[type=file][data-crop-ratio] 时才去拉(见 app.js)。
|
||||||
|
同样在这里登记带版本号的地址,绝不在没有裁剪的页面白拉。 */ ?>
|
||||||
|
<meta name="fb-cropper" content="<?= e(json_encode([
|
||||||
|
'css' => asset('/assets/vendor/cropper/cropper.min.css'),
|
||||||
|
'js' => asset('/assets/vendor/cropper/cropper.min.js'),
|
||||||
|
], JSON_UNESCAPED_UNICODE | JSON_HEX_APOS | JSON_HEX_QUOT)) ?>">
|
||||||
</head>
|
</head>
|
||||||
<?php /* 主题色:把 --brand / --brand-2 / --brand-ink 三个变量在 <body> 上换成本模块的色,
|
<?php /* 主题色:把 --brand / --brand-2 / --brand-ink 三个变量在 <body> 上换成本模块的色,
|
||||||
下面所有用到品牌色的地方(按钮、链接、页头高亮…)自动跟着变。
|
下面所有用到品牌色的地方(按钮、链接、页头高亮…)自动跟着变。
|
||||||
|
|||||||
+202
-156
@@ -194,24 +194,26 @@ $ledMe = (int)($me ?? 0);
|
|||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="ck-grid">
|
<?php /* 用户 2026-10-04 第 11 批 #7 两句原话:
|
||||||
|
· 「那个图,怎么数据都是 0 啊,没有盘点数据,就用账目的初始数据就可以了」
|
||||||
|
→ 数据源已在 Ledger::entrySeries 修好(没盘点过就从 0 画到当前值)。
|
||||||
|
· 「图下面的条目不用显示详情,只需要显示每个账目的颜色,
|
||||||
|
就是说哪个颜色代表哪个账目,就可以了。」
|
||||||
|
→ 原来每条账目一张小卡(标题 + 类型 + 点数 + 一张小图),信息冗余;
|
||||||
|
现在改成**一张合并的多色折线图**(每条账目一条线、用图例色),
|
||||||
|
下面只留一排颜色图例(色块 + 名称),不再堆详情。 */ ?>
|
||||||
|
<?= chart_multi($charts, 900) ?>
|
||||||
|
<div class="ck-legend">
|
||||||
<?php foreach ($charts as $ch): ?>
|
<?php foreach ($charts as $ch): ?>
|
||||||
<?php $en = $ch['entry']; $ek = (string)$en['kind']; ?>
|
<?php $en = $ch['entry']; $ek = (string)$en['kind']; ?>
|
||||||
<div class="ck-card" data-ck-card="<?= e($ek) ?>">
|
<span class="ck-lg-item" data-ck-kind="<?= e($ek) ?>">
|
||||||
<div class="ck-head">
|
<i class="ck-lg-dot" style="background: <?= e((string)$ch['color']) ?>;"></i>
|
||||||
<b><?= e((string)$en['title']) ?></b>
|
<?= e((string)$en['title']) ?>
|
||||||
<span class="small muted">
|
</span>
|
||||||
<?= \App\Ledger::kindIcon($ek) ?> <?= e(\App\Ledger::kindWord($ek)) ?>
|
|
||||||
· <?= count($ch['series']) ?> 个点
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<?= chart_line($ch['series'], (string)$en['title'], \App\Ledger::isDebt($ek) ? 'debt' : 'asset', 320) ?>
|
|
||||||
</div>
|
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</div>
|
</div>
|
||||||
<p class="small muted" style="margin:8px 2px 0">
|
<p class="small muted" style="margin:8px 2px 0">
|
||||||
没有盘点记录的账目只会显示一个点(它一直没变过)——
|
没盘点过的账目按「从 0 到现在」画 —— 想让它出现真实波动,在下面账目表里点「盘点」。
|
||||||
在下面账目表里点「盘点」记下当前金额,这条线就长出来了。
|
|
||||||
</p>
|
</p>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
@@ -257,116 +259,26 @@ $ledMe = (int)($me ?? 0);
|
|||||||
用来修改名称、挂在谁名下、备注等。」
|
用来修改名称、挂在谁名下、备注等。」
|
||||||
→ 所以这两个是**并列的按钮**,各自弹自己的面板;
|
→ 所以这两个是**并列的按钮**,各自弹自己的面板;
|
||||||
不再是一个「操作」里塞两坨表单(那要展开两层才够得着)。 */ ?>
|
不再是一个「操作」里塞两坨表单(那要展开两层才够得着)。 */ ?>
|
||||||
|
<?php /* 用户第 11 批 #7:「修改按钮,点击后弹框,然后修改信息」;
|
||||||
|
同时这里也是**账目页卡顿的根源**:原来每行内联「盘点」「修改」
|
||||||
|
两个面板(各一个 <form> + 一个选人组件),账目多时就是几千个 form
|
||||||
|
—— 密码管理器(Bitwarden)逐项扫描把主线程占死。
|
||||||
|
→ 现在行上只留两个带数据的按钮,真正的表单全页只有一份(见页尾 #ledPanels)。 */ ?>
|
||||||
<?php if ($canWrite): $histN = count(\App\Ledger::history($eid)); ?>
|
<?php if ($canWrite): $histN = count(\App\Ledger::history($eid)); ?>
|
||||||
<button type="button" class="btn btn-ghost btn-sm led-act"
|
<button type="button" class="btn btn-ghost btn-sm led-act"
|
||||||
data-led-open="recon-<?= $eid ?>">盘点</button>
|
data-led-open="recon"
|
||||||
|
data-led-id="<?= $eid ?>"
|
||||||
|
data-led-title="<?= e((string)$en['title']) ?>"
|
||||||
|
data-led-amt="<?= e(\App\Ledger::amountYuan((int)$en['amount_cents'])) ?>"
|
||||||
|
data-led-hist="<?= (int)$histN ?>">盘点</button>
|
||||||
<button type="button" class="btn btn-ghost btn-sm led-act"
|
<button type="button" class="btn btn-ghost btn-sm led-act"
|
||||||
data-led-open="edit-<?= $eid ?>">修改</button>
|
data-led-open="edit"
|
||||||
|
data-led-id="<?= $eid ?>"
|
||||||
<div class="led-panel" id="recon-<?= $eid ?>" hidden>
|
data-led-title="<?= e((string)$en['title']) ?>"
|
||||||
<form method="post" action="<?= e(url('ledger/reconcile')) ?>" class="led-recon">
|
data-led-amt="<?= e(\App\Ledger::amountYuan((int)$en['amount_cents'])) ?>"
|
||||||
<?= csrf_field() ?>
|
data-led-note="<?= e((string)($en['note'] ?? '')) ?>"
|
||||||
<input type="hidden" name="ledger_id" value="<?= $curId ?>">
|
data-led-kind="<?= e($ek) ?>"
|
||||||
<input type="hidden" name="id" value="<?= $eid ?>">
|
data-led-person="<?= (int)($en['person_id'] ?? 0) ?>">修改</button>
|
||||||
<h4 class="led-form-h">盘点「<?= e((string)$en['title']) ?>」</h4>
|
|
||||||
<div class="field">
|
|
||||||
<label>最新金额(元)</label>
|
|
||||||
<input class="input" name="amount" inputmode="decimal" required
|
|
||||||
value="<?= e(\App\Ledger::amountYuan((int)$en['amount_cents'])) ?>">
|
|
||||||
<span class="tip">盘点前的金额会记进历史,图表上就多一个点。</span>
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label>盘点备注</label>
|
|
||||||
<input class="input" name="note" placeholder="如:2026 年初盘点">
|
|
||||||
</div>
|
|
||||||
<div class="btn-row">
|
|
||||||
<button class="btn btn-sm" type="submit">记下这次盘点</button>
|
|
||||||
<button class="btn btn-ghost btn-sm" type="button" data-led-close>取消</button>
|
|
||||||
</div>
|
|
||||||
<?php if ($histN): ?>
|
|
||||||
<details class="led-hist">
|
|
||||||
<summary class="link-quiet">看历史(<?= $histN ?> 次)</summary>
|
|
||||||
<ul class="led-hist-list">
|
|
||||||
<?php foreach (\App\Ledger::history($eid) as $hs): ?>
|
|
||||||
<li>
|
|
||||||
<span class="nowrap"><?= e(substr((string)$hs['created_at'], 0, 10)) ?></span>
|
|
||||||
<b><?= e($money((int)$hs['amount_cents'])) ?></b>
|
|
||||||
<?php if (($hs['note'] ?? '') !== ''): ?>
|
|
||||||
<span class="muted small"><?= e((string)$hs['note']) ?></span>
|
|
||||||
<?php endif; ?>
|
|
||||||
</li>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</ul>
|
|
||||||
</details>
|
|
||||||
<?php endif; ?>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="led-panel" id="edit-<?= $eid ?>" hidden>
|
|
||||||
<?php /* 用户第 3 条:修改的作用域是**名称 / 挂在谁名下 / 备注等** ——
|
|
||||||
与"更正金额"分开:改金额属于"当初录错了",那走盘点(留历史);
|
|
||||||
这里改的是描述性字段,所以**也可以含类别与金额**(都是描述),
|
|
||||||
但文案上讲清楚它与盘点的区别。 */ ?>
|
|
||||||
<form method="post" action="<?= e(url('ledger/entry_update')) ?>">
|
|
||||||
<?= csrf_field() ?>
|
|
||||||
<input type="hidden" name="ledger_id" value="<?= $curId ?>">
|
|
||||||
<input type="hidden" name="id" value="<?= $eid ?>">
|
|
||||||
<h4 class="led-form-h">修改「<?= e((string)$en['title']) ?>」</h4>
|
|
||||||
<p class="tip" style="margin:0 0 8px">
|
|
||||||
改的是名称、归属与备注。若只是**金额变了**(不是录错),
|
|
||||||
请用「盘点」——那样才会记进历史、在图上多一个点。
|
|
||||||
</p>
|
|
||||||
<div class="field">
|
|
||||||
<label>名称</label>
|
|
||||||
<input class="input" name="title" value="<?= e((string)$en['title']) ?>" required>
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label>挂在谁名下</label>
|
|
||||||
<?php
|
|
||||||
$ppName = 'person_id';
|
|
||||||
$ppPeople = $people;
|
|
||||||
$ppSelected = !empty($en['person_id']) ? [(int)$en['person_id']] : [];
|
|
||||||
$ppMode = 'single';
|
|
||||||
$ppOpenBtn = '更换归属人';
|
|
||||||
$ppTitle = '这笔账挂在谁名下';
|
|
||||||
$ppEmptyText= '不指定';
|
|
||||||
$ppAllowEmpty = true;
|
|
||||||
require __DIR__ . '/../_person_picker.php';
|
|
||||||
?>
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label>类别</label>
|
|
||||||
<select class="select" name="kind">
|
|
||||||
<?php foreach (\App\Ledger::KINDS as $k => $v): ?>
|
|
||||||
<option value="<?= e($k) ?>" <?= $ek === $k ? 'selected' : '' ?>>
|
|
||||||
<?= e($v[2] . ' ' . $v[0]) ?><?= $v[1] === 'debt' ? '(负债)' : '' ?>
|
|
||||||
</option>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</select>
|
|
||||||
</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 class="btn-row">
|
|
||||||
<button class="btn btn-sm" type="submit">保存</button>
|
|
||||||
<button class="btn btn-ghost btn-sm" type="button" data-led-close>取消</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
<form method="post" action="<?= e(url('ledger/entry_del')) ?>" class="res-inline"
|
|
||||||
style="margin-top:8px">
|
|
||||||
<?= csrf_field() ?>
|
|
||||||
<input type="hidden" name="ledger_id" value="<?= $curId ?>">
|
|
||||||
<input type="hidden" name="id" value="<?= $eid ?>">
|
|
||||||
<button class="btn btn-ghost btn-sm" type="submit"
|
|
||||||
onclick="return confirm('删掉这笔账目?')">删除这笔</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -384,6 +296,102 @@ $ledMe = (int)($me ?? 0);
|
|||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<?php /* ==================== 账目行的两个弹框(全页各一份)====================
|
||||||
|
与名册的 #rosterMenus 同一套思路:行上只留按钮,表单集中在这里。
|
||||||
|
这是账目页卡顿的**根治办法** —— 原来每行两个面板,
|
||||||
|
账目一多就是几千个 form(用户报的 Bitwarden 冲突就是这么来的)。 */ ?>
|
||||||
|
<?php if ($canWrite): ?>
|
||||||
|
<div class="led-modal" id="ledPanels" hidden>
|
||||||
|
<div class="led-mask" data-led-close></div>
|
||||||
|
|
||||||
|
<div class="led-panel-lg" id="ledReconPanel" hidden role="dialog" aria-modal="true" aria-label="盘点">
|
||||||
|
<div class="led-ph"><b>盘点「<span class="ld-title"></span>」</b>
|
||||||
|
<button type="button" class="led-x" data-led-close aria-label="关闭">✕</button></div>
|
||||||
|
<form method="post" action="<?= e(url('ledger/reconcile')) ?>" class="led-recon">
|
||||||
|
<?= csrf_field() ?>
|
||||||
|
<input type="hidden" name="ledger_id" value="<?= $curId ?>">
|
||||||
|
<input type="hidden" name="id" value="">
|
||||||
|
<div class="led-pb">
|
||||||
|
<div class="field">
|
||||||
|
<label>最新金额(元)</label>
|
||||||
|
<input class="input" name="amount" inputmode="decimal" required>
|
||||||
|
<span class="tip">盘点前的金额会记进历史,图上就多一个点。</span>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>盘点备注</label>
|
||||||
|
<input class="input" name="note" placeholder="如:2026 年初盘点">
|
||||||
|
</div>
|
||||||
|
<details class="led-hist" hidden>
|
||||||
|
<summary class="link-quiet"></summary>
|
||||||
|
<div class="led-hist-box"></div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
<div class="led-pf">
|
||||||
|
<button class="btn" type="submit">记下这次盘点</button>
|
||||||
|
<button class="btn btn-ghost" type="button" data-led-close>取消</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="led-panel-lg" id="ledEditPanel" hidden role="dialog" aria-modal="true" aria-label="修改">
|
||||||
|
<div class="led-ph"><b>修改「<span class="ld-title"></span>」</b>
|
||||||
|
<button type="button" class="led-x" data-led-close aria-label="关闭">✕</button></div>
|
||||||
|
<form method="post" action="<?= e(url('ledger/entry_update')) ?>">
|
||||||
|
<?= csrf_field() ?>
|
||||||
|
<input type="hidden" name="ledger_id" value="<?= $curId ?>">
|
||||||
|
<input type="hidden" name="id" value="">
|
||||||
|
<div class="led-pb">
|
||||||
|
<div class="field">
|
||||||
|
<label>名称</label>
|
||||||
|
<input class="input" name="title" required>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>挂在谁名下</label>
|
||||||
|
<?php /* ⚠️ 选人组件只在这里渲染**一份**(原来每行一份 = 卡顿主因之一) */ ?>
|
||||||
|
<?php
|
||||||
|
$ppName = 'person_id';
|
||||||
|
$ppPeople = $people;
|
||||||
|
$ppSelected = [];
|
||||||
|
$ppMode = 'single';
|
||||||
|
$ppOpenBtn = '更换归属人';
|
||||||
|
$ppTitle = '这笔账挂在谁名下';
|
||||||
|
$ppEmptyText= '不指定';
|
||||||
|
$ppAllowEmpty = true;
|
||||||
|
require __DIR__ . '/../_person_picker.php';
|
||||||
|
?>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>类别</label>
|
||||||
|
<select class="select" 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>金额(元)</label>
|
||||||
|
<input class="input" name="amount" required>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>备注</label>
|
||||||
|
<input class="input" name="note">
|
||||||
|
</div>
|
||||||
|
<p class="tip" style="margin:0">
|
||||||
|
改的是名称、归属与备注。若只是<b>金额变了</b>(不是录错),请用「盘点」——
|
||||||
|
那样才会记进历史、在图上多一个点。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="led-pf">
|
||||||
|
<button class="btn" type="submit">保存</button>
|
||||||
|
<button class="btn btn-ghost" type="button" data-led-close>取消</button>
|
||||||
|
<button class="btn btn-danger" type="submit" formaction="<?= e(url('ledger/entry_del')) ?>"
|
||||||
|
onclick="return confirm('删掉这笔账目?')">删除这笔</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
/* ---- 「记一笔」开合 ---- */
|
/* ---- 「记一笔」开合 ---- */
|
||||||
@@ -397,43 +405,81 @@ $ledMe = (int)($me ?? 0);
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- 每行「盘点 / 修改」两个按钮:弹各自的面板(v24 第 10 批 #3)----
|
/* ---- 行上「盘点 / 修改」两个按钮 → 打开**全页共享的弹框**(第 11 批 #7)----
|
||||||
面板贴着那一行展开(不是弹窗遮罩),改完就在原地看得到结果。
|
⚠️ 旧版是"把面板插到那一行后面",于是每行都得内联一整套表单(含选人组件):
|
||||||
同时只开一个;点别处 / Esc 收起。 */
|
账目多的时候就是几千个 form,密码管理器逐个扫描 → 页面卡死
|
||||||
var openPanel = null;
|
(用户报的"和之前的名录一样,还是 bitwarden 冲突")。
|
||||||
function closePanel() {
|
现在表单只有一份(#ledPanels),打开时把该行的数据灌进去。 */
|
||||||
if (openPanel) { openPanel.hidden = true; openPanel = null; }
|
var wrap = document.getElementById('ledPanels');
|
||||||
}
|
if (wrap) {
|
||||||
document.addEventListener('click', function (e) {
|
var reconPanel = document.getElementById('ledReconPanel');
|
||||||
var t = e.target;
|
var editPanel = document.getElementById('ledEditPanel');
|
||||||
if (!t || !t.closest) { return; }
|
|
||||||
if (t.closest('[data-led-close]')) { closePanel(); return; }
|
function closeAll() {
|
||||||
var trigger = t.closest('[data-led-open]');
|
wrap.hidden = true;
|
||||||
if (trigger) {
|
if (reconPanel) { reconPanel.hidden = true; }
|
||||||
var id = trigger.getAttribute('data-led-open');
|
if (editPanel) { editPanel.hidden = true; }
|
||||||
var panel = document.getElementById(id);
|
|
||||||
if (!panel) { return; }
|
|
||||||
var wasOpen = (panel === openPanel);
|
|
||||||
closePanel();
|
|
||||||
if (wasOpen) { return; } // 再点一次 = 收起
|
|
||||||
panel.hidden = false;
|
|
||||||
openPanel = panel;
|
|
||||||
// 把面板插到这一行**后面**(作为整行的兄弟),而不是塞在 <td> 里 ——
|
|
||||||
// 表格单元格会被列宽切碎,表单在格子里根本排不开。
|
|
||||||
var tr = trigger.closest('tr');
|
|
||||||
if (tr && panel.parentNode !== tr.parentNode) {
|
|
||||||
tr.parentNode.insertBefore(panel, tr.nextSibling);
|
|
||||||
}
|
|
||||||
// 焦点给到第一个输入框:点完就能打字
|
|
||||||
var inp = panel.querySelector('input:not([type=hidden]), select');
|
|
||||||
if (inp) { inp.focus(); }
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
if (openPanel && !openPanel.contains(t)) { closePanel(); }
|
|
||||||
}, true);
|
document.addEventListener('click', function (e) {
|
||||||
document.addEventListener('keydown', function (e) {
|
var t = e.target;
|
||||||
if (e.key === 'Escape') { closePanel(); }
|
if (!t || !t.closest) { return; }
|
||||||
});
|
|
||||||
|
var trigger = t.closest('[data-led-open]');
|
||||||
|
if (trigger) {
|
||||||
|
e.preventDefault();
|
||||||
|
var which = trigger.getAttribute('data-led-open');
|
||||||
|
var id = trigger.getAttribute('data-led-id');
|
||||||
|
var title = trigger.getAttribute('data-led-title') || '';
|
||||||
|
var amt = trigger.getAttribute('data-led-amt') || '';
|
||||||
|
var panel = (which === 'recon') ? reconPanel : editPanel;
|
||||||
|
if (!panel) { return; }
|
||||||
|
|
||||||
|
panel.querySelector('input[name="id"]').value = id;
|
||||||
|
panel.querySelector('.ld-title').textContent = title;
|
||||||
|
var amtInp = panel.querySelector('input[name="amount"]');
|
||||||
|
if (amtInp) { amtInp.value = amt; }
|
||||||
|
|
||||||
|
if (which === 'edit') {
|
||||||
|
var tInp = panel.querySelector('input[name="title"]');
|
||||||
|
if (tInp) { tInp.value = title; }
|
||||||
|
var nInp = panel.querySelector('input[name="note"]');
|
||||||
|
if (nInp) { nInp.value = trigger.getAttribute('data-led-note') || ''; }
|
||||||
|
var kSel = panel.querySelector('select[name="kind"]');
|
||||||
|
if (kSel) { kSel.value = trigger.getAttribute('data-led-kind') || 'other'; }
|
||||||
|
// 归属人:选人组件是**单选**,用它的接口重置选中(见 _person_picker.php)
|
||||||
|
var pid = trigger.getAttribute('data-led-person') || '0';
|
||||||
|
if (window.ppSetSelected) { window.ppSetSelected('person_id', pid); }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (which === 'recon') {
|
||||||
|
// 历史的条数以按钮为准(服务端已算好),点开才展开,避免每行都查一次库
|
||||||
|
var histN = parseInt(trigger.getAttribute('data-led-hist') || '0', 10) || 0;
|
||||||
|
var hist = panel.querySelector('.led-hist');
|
||||||
|
var hsum = panel.querySelector('.led-hist summary');
|
||||||
|
var hbox = panel.querySelector('.led-hist-box');
|
||||||
|
if (hist && hsum) {
|
||||||
|
hist.hidden = histN === 0;
|
||||||
|
hsum.textContent = '看历史(' + histN + ' 次)';
|
||||||
|
if (hbox) { hbox.innerHTML = ''; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
reconPanel.hidden = (which !== 'recon');
|
||||||
|
editPanel.hidden = (which !== 'edit');
|
||||||
|
wrap.hidden = false;
|
||||||
|
var focusInp = panel.querySelector('input:not([type=hidden]), select');
|
||||||
|
if (focusInp) { focusInp.focus(); }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (t.closest('[data-led-close]')) { e.preventDefault(); closeAll(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('keydown', function (e) {
|
||||||
|
if (e.key === 'Escape') { closeAll(); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/* ---- 图表按类别筛选("所有账目"在几百条时会变成一坨)---- */
|
/* ---- 图表按类别筛选("所有账目"在几百条时会变成一坨)---- */
|
||||||
var bar = document.querySelector('[data-ck-filter]');
|
var bar = document.querySelector('[data-ck-filter]');
|
||||||
|
|||||||
+167
-11
@@ -24,7 +24,9 @@ $dtFromOld = static function (string $name, ?string $dbValue): ?string {
|
|||||||
};
|
};
|
||||||
$dtStart = $dtFromOld('starts_at', $m['starts_at'] ?? null);
|
$dtStart = $dtFromOld('starts_at', $m['starts_at'] ?? null);
|
||||||
$dtEnd = $dtFromOld('ends_at', $m['ends_at'] ?? null);
|
$dtEnd = $dtFromOld('ends_at', $m['ends_at'] ?? null);
|
||||||
$selCinema = (int)old('cinema_id', $m['cinema_id'] ?? 0);
|
// 刚用「新建影院」弹框加完一家 → 回到这里要**自动选中**它(见 index.php 的 cinemas_save)。
|
||||||
|
// 优先级:老输入 > ?cinema=(刚新建的)> 记录里的值。
|
||||||
|
$selCinema = (int)old('cinema_id', (int)($_GET['cinema'] ?? 0) ?: ($m['cinema_id'] ?? 0));
|
||||||
$curAttendees = array_map('intval', array_column($m['attendees'] ?? [], 'id'));
|
$curAttendees = array_map('intval', array_column($m['attendees'] ?? [], 'id'));
|
||||||
$selAttendees = array_map('intval', (array)old('attendees', $curAttendees));
|
$selAttendees = array_map('intval', (array)old('attendees', $curAttendees));
|
||||||
?>
|
?>
|
||||||
@@ -90,20 +92,43 @@ $selAttendees = array_map('intval', (array)old('attendees', $curAttendees));
|
|||||||
<span class="tip">散场时间可不填。</span>
|
<span class="tip">散场时间可不填。</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<?php /* 用户 2026-10-04 第 11 批 #4(三件事):
|
||||||
|
① 「选择影院的时候,影院的颜色要出现在下拉列表中,用来显示不同的影院」
|
||||||
|
→ 原生 <option> **画不了色块**(浏览器只渲染纯文本),
|
||||||
|
所以这里换成自定义下拉:按钮 + 弹出列表,每行左侧一个影院色点。
|
||||||
|
真实值仍然提交给隐藏的 <input name="cinema_id">(表单契约不变)。
|
||||||
|
② 「管理影院按钮,改为新建影院,点击后弹框,添加新影院,不要跳转到管理影院的页面」
|
||||||
|
→ 弹框里放"地方/名称/颜色"三个字段(影院管理页那张表单的核心),
|
||||||
|
提交时带 back=movies/new,服务端存完回到本页并自动选中新影院。
|
||||||
|
③ 下拉/弹框的样式与全站统一(宣纸底色、品牌色描边)。 */ ?>
|
||||||
<div class="field full">
|
<div class="field full">
|
||||||
<label for="cinema_id">影院</label>
|
<label for="cinema_id">影院</label>
|
||||||
<div class="cinema-select-row">
|
<div class="cinema-select-row">
|
||||||
<select class="select" id="cinema_id" name="cinema_id">
|
<div class="cs" id="cinemaPicker" data-open="0">
|
||||||
<option value="0">不记录影院</option>
|
<button type="button" class="cs-btn" id="csBtn" aria-haspopup="listbox" aria-expanded="false">
|
||||||
<?php foreach ($cinemas as $c): ?>
|
<span class="cs-dot" id="csDot"<?= $selCinema > 0 ? '' : ' hidden' ?>></span>
|
||||||
<option value="<?= (int)$c['id'] ?>" <?= $selCinema === (int)$c['id'] ? 'selected' : '' ?>>
|
<span class="cs-text" id="csText">不记录影院</span>
|
||||||
<?= e(trim(((string)($c['place'] ?? '') !== '' ? $c['place'] . ' · ' : '') . $c['name'])) ?>
|
<span class="cs-caret" aria-hidden="true">▾</span>
|
||||||
</option>
|
</button>
|
||||||
<?php endforeach; ?>
|
<div class="cs-menu" id="csMenu" hidden role="listbox">
|
||||||
</select>
|
<button type="button" class="cs-item" data-id="0" data-color="" role="option">
|
||||||
<a class="btn btn-ghost btn-sm" href="<?= e(url('movies/cinemas')) ?>">管理影院</a>
|
<span class="cs-dot is-none" aria-hidden="true"></span><span>不记录影院</span>
|
||||||
|
</button>
|
||||||
|
<?php foreach ($cinemas as $c): ?>
|
||||||
|
<button type="button" class="cs-item" role="option"
|
||||||
|
data-id="<?= (int)$c['id'] ?>"
|
||||||
|
data-color="<?= e((string)($c['color'] ?: '#888888')) ?>"
|
||||||
|
data-text="<?= e(trim(((string)($c['place'] ?? '') !== '' ? $c['place'] . ' · ' : '') . $c['name'])) ?>">
|
||||||
|
<span class="cs-dot" style="background: <?= e((string)($c['color'] ?: '#888888')) ?>;"></span>
|
||||||
|
<span><?= e(trim(((string)($c['place'] ?? '') !== '' ? $c['place'] . ' · ' : '') . $c['name'])) ?></span>
|
||||||
|
</button>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<input type="hidden" id="cinema_id" name="cinema_id" value="<?= (int)$selCinema ?>">
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-ghost btn-sm" id="cinemaNewBtn">新建影院</button>
|
||||||
</div>
|
</div>
|
||||||
<span class="tip">想记一家还没录过的影院?先去「管理影院」里添加,回来这里就能选。</span>
|
<span class="tip">刚新建的影院会自动选中;下拉里的色点就是它在日历与列表里的颜色。</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field full">
|
<div class="field full">
|
||||||
@@ -172,6 +197,48 @@ $selAttendees = array_map('intval', (array)old('attendees', $curAttendees));
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<?php /* ---- 新建影院:弹框(不跳转,见上)---- */ ?>
|
||||||
|
<div class="cn-modal" id="cinemaNewModal" hidden>
|
||||||
|
<div class="cn-mask" data-cn-close></div>
|
||||||
|
<div class="cn-panel" role="dialog" aria-modal="true" aria-label="新建影院">
|
||||||
|
<div class="cn-head"><b>新建影院</b>
|
||||||
|
<button type="button" class="cn-x" data-cn-close aria-label="关闭">✕</button></div>
|
||||||
|
<form method="post" action="<?= e(url('movies/cinemas_save')) ?>">
|
||||||
|
<?= csrf_field() ?>
|
||||||
|
<?php /* back=movies/new 让服务端存完回到**本页**(而不是甩去影院管理页) */ ?>
|
||||||
|
<input type="hidden" name="back" value="movies/new">
|
||||||
|
<div class="cn-body">
|
||||||
|
<div class="field">
|
||||||
|
<label>地方(可不填)</label>
|
||||||
|
<input class="input" name="place" placeholder="如:青岛" maxlength="100">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>影院名称 *</label>
|
||||||
|
<input class="input" name="name" required placeholder="如:万象城 CGV" maxlength="100">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>主题颜色</label>
|
||||||
|
<div class="color-row">
|
||||||
|
<input type="color" name="color" class="color-picker cn-color" value="#A65C30">
|
||||||
|
<span class="color-hex cn-hex">#A65C30</span>
|
||||||
|
<span class="cn-presets">
|
||||||
|
<?php foreach (['#A63030','#A65C30','#A68930','#6BA630','#30A67A','#3078A6','#6B30A6','#A6305C'] as $pc): ?>
|
||||||
|
<button type="button" class="preset-color cn-preset" data-color="<?= $pc ?>"
|
||||||
|
style="background: <?= $pc ?>;" title="<?= $pc ?>"></button>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span class="tip">这个颜色会出现在下拉、日历与观影列表上。</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="cn-foot">
|
||||||
|
<button class="btn" type="submit">添加</button>
|
||||||
|
<button class="btn btn-ghost" type="button" data-cn-close>取消</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 豆瓣自动补充:下拉联想(320ms 防抖 + 中文输入法组字期不请求)+ 一键抓取 -->
|
<!-- 豆瓣自动补充:下拉联想(320ms 防抖 + 中文输入法组字期不请求)+ 一键抓取 -->
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
@@ -327,4 +394,93 @@ $selAttendees = array_map('intval', (array)old('attendees', $curAttendees));
|
|||||||
xhr.send();
|
xhr.send();
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
/* ==================== 影院自定义下拉 + 新建影院弹框(第 11 批 #4)====================
|
||||||
|
① 下拉:原生 <option> 画不了色块,所以自己做一个"按钮 + 弹出列表"。
|
||||||
|
⚠️ 值仍然写进隐藏的 <input name="cinema_id">,服务端与既有测试都不用改。
|
||||||
|
② 新建影院:弹框里提交到 movies/cinemas_save(带 back=movies/new),
|
||||||
|
存完服务端会跳回本页并把新影院 id 放在 ?cinema= 上 → 模板已自动选中它。 */
|
||||||
|
(function () {
|
||||||
|
var picker = document.getElementById('cinemaPicker');
|
||||||
|
if (!picker) { return; }
|
||||||
|
var btn = document.getElementById('csBtn');
|
||||||
|
var menu = document.getElementById('csMenu');
|
||||||
|
var dot = document.getElementById('csDot');
|
||||||
|
var text = document.getElementById('csText');
|
||||||
|
var input = document.getElementById('cinema_id');
|
||||||
|
|
||||||
|
function close() { menu.hidden = true; btn.setAttribute('aria-expanded', 'false'); }
|
||||||
|
function openMenu() { menu.hidden = false; btn.setAttribute('aria-expanded', 'true'); }
|
||||||
|
|
||||||
|
function apply(id, color, label) {
|
||||||
|
input.value = String(id);
|
||||||
|
text.textContent = label || '不记录影院';
|
||||||
|
if (color) {
|
||||||
|
dot.hidden = false;
|
||||||
|
dot.style.background = color;
|
||||||
|
dot.classList.remove('is-none');
|
||||||
|
} else {
|
||||||
|
dot.hidden = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始状态:按当前值把按钮上的色点/文字同步过来
|
||||||
|
(function init() {
|
||||||
|
var cur = String(input.value || '0');
|
||||||
|
var items = menu.querySelectorAll('.cs-item');
|
||||||
|
for (var i = 0; i < items.length; i++) {
|
||||||
|
if (items[i].getAttribute('data-id') === cur) {
|
||||||
|
var lbl = items[i].querySelector('span:last-child');
|
||||||
|
apply(cur, items[i].getAttribute('data-color'), lbl ? lbl.textContent : '');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
btn.addEventListener('click', function (e) {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (menu.hidden) { openMenu(); } else { close(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
menu.addEventListener('click', function (e) {
|
||||||
|
var it = e.target.closest ? e.target.closest('.cs-item') : null;
|
||||||
|
if (!it) { return; }
|
||||||
|
e.stopPropagation();
|
||||||
|
var lbl2 = it.querySelector('span:last-child');
|
||||||
|
apply(it.getAttribute('data-id'), it.getAttribute('data-color'), lbl2 ? lbl2.textContent : '');
|
||||||
|
close();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 点外面关(冒泡阶段,只关下拉本身)
|
||||||
|
document.addEventListener('click', function () { close(); });
|
||||||
|
|
||||||
|
// ---- 新建影院弹框 ----
|
||||||
|
var modal = document.getElementById('cinemaNewModal');
|
||||||
|
var nbtn = document.getElementById('cinemaNewBtn');
|
||||||
|
if (modal && nbtn) {
|
||||||
|
nbtn.addEventListener('click', function () {
|
||||||
|
modal.hidden = false;
|
||||||
|
var f = modal.querySelector('input[name="name"]');
|
||||||
|
if (f) { f.focus(); }
|
||||||
|
});
|
||||||
|
modal.addEventListener('click', function (e) {
|
||||||
|
if (e.target.closest && e.target.closest('[data-cn-close]')) { modal.hidden = true; }
|
||||||
|
});
|
||||||
|
document.addEventListener('keydown', function (e) {
|
||||||
|
if (e.key === 'Escape') { modal.hidden = true; }
|
||||||
|
});
|
||||||
|
// 颜色选择器与预设色(复刻影院管理页那套,只是缩在弹框里)
|
||||||
|
var cp = modal.querySelector('.cn-color');
|
||||||
|
var hx = modal.querySelector('.cn-hex');
|
||||||
|
if (cp && hx) {
|
||||||
|
cp.addEventListener('input', function () { hx.textContent = cp.value; });
|
||||||
|
}
|
||||||
|
modal.addEventListener('click', function (e) {
|
||||||
|
var pv = e.target.closest ? e.target.closest('.cn-preset') : null;
|
||||||
|
if (!pv || !cp) { return; }
|
||||||
|
cp.value = pv.getAttribute('data-color');
|
||||||
|
if (hx) { hx.textContent = cp.value; }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
+111
-55
@@ -184,6 +184,18 @@ $renderGrid = function (array $list) use ($meId, $canMove, $groups): void {
|
|||||||
<a class="btn btn-ghost btn-sm person-edit" data-no-drag
|
<a class="btn btn-ghost btn-sm person-edit" data-no-drag
|
||||||
href="<?= e(url('people/edit', ['id' => $pid])) ?>"
|
href="<?= e(url('people/edit', ['id' => $pid])) ?>"
|
||||||
title="编辑 <?= e($pshow) ?> 的资料">编辑</a>
|
title="编辑 <?= e($pshow) ?> 的资料">编辑</a>
|
||||||
|
<?php /* 用户 2026-10-04 第 11 批 #2 原话:「在编辑后面,放一个按钮,删除,
|
||||||
|
用来删除一个人。」
|
||||||
|
⚠️ 它与「⋯→从我的名册移走」不是一件事,别混:
|
||||||
|
· 「从我的名册移走」= 只是不在**我的**名册里显示,人还在家里;
|
||||||
|
· 「删除」= 真的把这个人删掉(账号与亲属关系一起走,不可恢复)。
|
||||||
|
所以它必须是显式的红色按钮 + 二次确认,不能收进 ⋯ 里。
|
||||||
|
没有删除权限(非管理员)时不画,并给出原因提示。 */ ?>
|
||||||
|
<?php if (\App\Auth::isAdmin()): ?>
|
||||||
|
<button type="button" class="btn btn-danger btn-sm person-del"
|
||||||
|
data-pd-open="<?= $pid ?>" data-pd-name="<?= e($pshow) ?>"
|
||||||
|
title="删除 <?= e($pshow) ?>">删除</button>
|
||||||
|
<?php endif; ?>
|
||||||
<?php if ($canMove): ?>
|
<?php if ($canMove): ?>
|
||||||
<button type="button" class="btn btn-ghost btn-sm person-more-btn"
|
<button type="button" class="btn btn-ghost btn-sm person-more-btn"
|
||||||
data-pm-open="<?= $pid ?>"
|
data-pm-open="<?= $pid ?>"
|
||||||
@@ -317,26 +329,65 @@ $renderBlock = function (int $gid, string $name, array $list) use ($renderGrid):
|
|||||||
⚠️ 表单里留了原生「保存」按钮:即使 JS 出错,用户手动点它也能提交,
|
⚠️ 表单里留了原生「保存」按钮:即使 JS 出错,用户手动点它也能提交,
|
||||||
不会掉进"按钮点了没反应"的黑洞。 */ ?>
|
不会掉进"按钮点了没反应"的黑洞。 */ ?>
|
||||||
<?php if ($canMove): ?>
|
<?php if ($canMove): ?>
|
||||||
<div class="roster-menus" id="rosterMenus" hidden>
|
<?php /* 用户 2026-10-04 第 11 批 #2 原话:「名册的分组功能,点击一次之后,再次点击就失效了,
|
||||||
<div class="rm-panel" id="rmGroup" hidden>
|
应该**弹出一个框,就像选择观影人一样**,选择分组。」
|
||||||
|
所以分组从"贴在按钮下方的浮层"改成**居中弹框 + 遮罩**(与选人弹窗同一形态)。
|
||||||
|
⚠️ "第二次点击失效"的根因:旧实现把浮层贴在按钮下方,并在**捕获阶段**监听
|
||||||
|
文档点击来"点外面就关"——于是点第二次时,mousedown 先被关闭逻辑吃掉、
|
||||||
|
随后的 click 又把面板打开,视觉上就是"闪一下没反应/要用第三次才行"。
|
||||||
|
改成居中的模态框后,外部点击只关模态本身,触发按钮的语义不再冲突。 */ ?>
|
||||||
|
<div class="rm-modal" id="rosterMenus" hidden>
|
||||||
|
<div class="rm-mask" data-rm-close></div>
|
||||||
|
<div class="rm-panel" id="rmGroup" role="dialog" aria-modal="true" aria-label="选择分组" hidden>
|
||||||
|
<div class="rm-head"><b>把「<span class="pg-who"></span>」分到</b>
|
||||||
|
<button type="button" class="rm-x" data-rm-close aria-label="关闭">✕</button></div>
|
||||||
<form method="post" action="<?= e(url('people/group')) ?>" class="pg-menu">
|
<form method="post" action="<?= e(url('people/group')) ?>" class="pg-menu">
|
||||||
<?= csrf_field() ?>
|
<?= csrf_field() ?>
|
||||||
<input type="hidden" name="person_id" value="">
|
<input type="hidden" name="person_id" value="">
|
||||||
<p class="pg-title">把「<b class="pg-who"></b>」分到</p>
|
<div class="rm-body">
|
||||||
<label class="pg-item">
|
|
||||||
<input type="radio" name="group_id" value="">
|
|
||||||
<span>未分类</span>
|
|
||||||
</label>
|
|
||||||
<?php foreach ($groups as $g): ?>
|
|
||||||
<label class="pg-item">
|
<label class="pg-item">
|
||||||
<input type="radio" name="group_id" value="<?= (int)$g['id'] ?>">
|
<input type="radio" name="group_id" value="">
|
||||||
<span><?= e((string)$g['name']) ?></span>
|
<span>未分类</span>
|
||||||
</label>
|
</label>
|
||||||
<?php endforeach; ?>
|
<?php foreach ($groups as $g): ?>
|
||||||
<button class="btn btn-sm pg-save" type="submit">保存</button>
|
<label class="pg-item">
|
||||||
|
<input type="radio" name="group_id" value="<?= (int)$g['id'] ?>">
|
||||||
|
<span><?= e((string)$g['name']) ?></span>
|
||||||
|
</label>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<div class="rm-foot">
|
||||||
|
<button class="btn" type="submit">保存</button>
|
||||||
|
<button class="btn btn-ghost" type="button" data-rm-close>取消</button>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<div class="rm-panel" id="rmMore" hidden>
|
|
||||||
|
<div class="rm-panel" id="rmDel" role="dialog" aria-modal="true" aria-label="删除成员" hidden>
|
||||||
|
<div class="rm-head"><b>删除「<span class="pd-who"></span>」</b>
|
||||||
|
<button type="button" class="rm-x" data-rm-close aria-label="关闭">✕</button></div>
|
||||||
|
<?php /* ⚠️ 路由是 people/destroy(与详情页底部那个删除同一个)——
|
||||||
|
不要写成 people/delete,那会 404 且看起来像"点了没反应"。 */ ?>
|
||||||
|
<form method="post" action="<?= e(url('people/destroy')) ?>" class="pd-form">
|
||||||
|
<?= csrf_field() ?>
|
||||||
|
<input type="hidden" name="id" value="">
|
||||||
|
<div class="rm-body">
|
||||||
|
<p class="rm-warn">
|
||||||
|
会把他从名册中删除,<b>并且</b>删掉他的登录账号与全部亲属关系连线。
|
||||||
|
<b>不可恢复。</b>
|
||||||
|
</p>
|
||||||
|
<p class="small muted" style="margin:8px 0 0">
|
||||||
|
只是"不想在名册里看到他"?那用卡片上的「⋯ → 从我的名册移走」,人还留在家里。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="rm-foot">
|
||||||
|
<button class="btn btn-danger" type="submit">确认删除</button>
|
||||||
|
<button class="btn btn-ghost" type="button" data-rm-close>取消</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rm-panel" id="rmMore" role="dialog" aria-modal="true" aria-label="更多操作" hidden>
|
||||||
<p class="small muted person-move is-kin" hidden style="margin:0 0 6px">
|
<p class="small muted person-move is-kin" hidden style="margin:0 0 6px">
|
||||||
因家谱关系自动加入;解除关系后自动移出。
|
因家谱关系自动加入;解除关系后自动移出。
|
||||||
</p>
|
</p>
|
||||||
@@ -371,91 +422,96 @@ $renderBlock = function (int $gid, string $name, array $list) use ($renderGrid):
|
|||||||
syncClear();
|
syncClear();
|
||||||
})();
|
})();
|
||||||
|
|
||||||
<?php /* ==================== 共享浮层的交互(v24 第 10 批 #6)====================
|
<?php /* ==================== 卡片动作弹框的交互(v24 第 11 批 #2 重写)====================
|
||||||
一行只留一个按钮,真正的表单在 #rosterMenus 里(全页一份)。
|
一行只留按钮,真正的表单在 #rosterMenus 里(全页一份,见前面的说明)。
|
||||||
这里负责:点按钮 → 把该行的数据填进共享表单 → 浮层贴着那个按钮弹出来。 */ ?>
|
这一版把"贴按钮的浮层"换成**居中模态框**(与选人弹窗同形态)—— 用户明确要求
|
||||||
|
「应该弹出一个框,就像选择观影人一样」。
|
||||||
|
|
||||||
|
⚠️ 为什么旧版"点第二次会失效":
|
||||||
|
旧实现在**捕获阶段**监听 document 的 click,只要目标不在浮层里就 closeAll()。
|
||||||
|
点第二次时事件顺序是 mousedown → (捕获阶段 closeAll)→ click(又打开)→ …
|
||||||
|
于是按钮像是"时灵时不灵"。模态框把"关闭"收敛到遮罩/关闭键/取消键上,
|
||||||
|
按钮自己只负责"打开",语义不再打架。
|
||||||
|
⚠️ 外部点击用**冒泡**阶段监听,且只认遮罩本身(data-rm-close),
|
||||||
|
不用"点哪儿都关"那种一刀切 —— 那正是上一版出问题的写法。 */ ?>
|
||||||
(function () {
|
(function () {
|
||||||
var wrap = document.getElementById('rosterMenus');
|
var wrap = document.getElementById('rosterMenus');
|
||||||
if (!wrap) { return; }
|
if (!wrap) { return; }
|
||||||
var gPanel = document.getElementById('rmGroup');
|
var gPanel = document.getElementById('rmGroup');
|
||||||
|
var dPanel = document.getElementById('rmDel');
|
||||||
var mPanel = document.getElementById('rmMore');
|
var mPanel = document.getElementById('rmMore');
|
||||||
var openFor = null; // 当前为哪张卡开着
|
|
||||||
|
|
||||||
function closeAll() {
|
function closeAll() {
|
||||||
wrap.hidden = true;
|
wrap.hidden = true;
|
||||||
if (gPanel) { gPanel.hidden = true; }
|
[gPanel, dPanel, mPanel].forEach(function (p) { if (p) { p.hidden = true; } });
|
||||||
if (mPanel) { mPanel.hidden = true; }
|
|
||||||
openFor = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 浮层贴到触发按钮下方;贴太靠右就向左收,贴太靠下就翻到上方 */
|
function open(panel) {
|
||||||
function place(panel, btn) {
|
[gPanel, dPanel, mPanel].forEach(function (p) { if (p && p !== panel) { p.hidden = true; } });
|
||||||
var b = btn.getBoundingClientRect();
|
panel.hidden = false;
|
||||||
var w = panel.offsetWidth, h = panel.offsetHeight;
|
wrap.hidden = false;
|
||||||
var vw = document.documentElement.clientWidth, vh = document.documentElement.clientHeight;
|
// 焦点进面板:点开就能用键盘选(Esc 也能关,见下)
|
||||||
var left = Math.min(Math.max(8, b.right - w), vw - w - 8);
|
var first = panel.querySelector('input:not([type=hidden]), button');
|
||||||
var top = b.bottom + 6;
|
if (first) { first.focus(); }
|
||||||
if (top + h > vh - 8) { top = Math.max(8, b.top - h - 6); }
|
|
||||||
panel.style.left = Math.round(left) + 'px';
|
|
||||||
panel.style.top = Math.round(top) + 'px';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- 打开:分组 ----
|
||||||
document.addEventListener('click', function (e) {
|
document.addEventListener('click', function (e) {
|
||||||
var t = e.target;
|
var t = e.target;
|
||||||
if (!t || !t.closest) { return; }
|
if (!t || !t.closest) { return; }
|
||||||
|
|
||||||
// ---- 分组 ----
|
|
||||||
var gb = t.closest('[data-pg-open]');
|
var gb = t.closest('[data-pg-open]');
|
||||||
if (gb) {
|
if (gb) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
var pid = gb.getAttribute('data-pg-open');
|
|
||||||
var cur = parseInt(gb.getAttribute('data-pg-cur') || '0', 10) || 0;
|
|
||||||
var form = gPanel.querySelector('form');
|
var form = gPanel.querySelector('form');
|
||||||
form.querySelector('input[name="person_id"]').value = pid;
|
form.querySelector('input[name="person_id"]').value = gb.getAttribute('data-pg-open');
|
||||||
gPanel.querySelector('.pg-who').textContent = gb.getAttribute('data-pg-name') || '';
|
gPanel.querySelector('.pg-who').textContent = gb.getAttribute('data-pg-name') || '';
|
||||||
// 预选当前分组
|
// 预选当前分组(点开就知道他现在在哪一栏)
|
||||||
|
var cur = parseInt(gb.getAttribute('data-pg-cur') || '0', 10) || 0;
|
||||||
Array.prototype.forEach.call(form.querySelectorAll('input[name="group_id"]'), function (r) {
|
Array.prototype.forEach.call(form.querySelectorAll('input[name="group_id"]'), function (r) {
|
||||||
r.checked = (parseInt(r.value || '0', 10) || 0) === cur;
|
r.checked = (parseInt(r.value || '0', 10) || 0) === cur;
|
||||||
});
|
});
|
||||||
mPanel.hidden = true;
|
open(gPanel);
|
||||||
gPanel.hidden = false;
|
|
||||||
wrap.hidden = false;
|
|
||||||
place(gPanel, gb);
|
|
||||||
openFor = pid;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- ⋯ ----
|
// ---- 打开:删除 ----
|
||||||
|
var db = t.closest('[data-pd-open]');
|
||||||
|
if (db) {
|
||||||
|
e.preventDefault();
|
||||||
|
dPanel.querySelector('form input[name="id"]').value = db.getAttribute('data-pd-open');
|
||||||
|
dPanel.querySelector('.pd-who').textContent = db.getAttribute('data-pd-name') || '';
|
||||||
|
open(dPanel);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 打开:⋯ ----
|
||||||
var mb = t.closest('[data-pm-open]');
|
var mb = t.closest('[data-pm-open]');
|
||||||
if (mb) {
|
if (mb) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
var pid2 = mb.getAttribute('data-pm-open');
|
|
||||||
var kin = mb.getAttribute('data-pm-hint') === '1';
|
var kin = mb.getAttribute('data-pm-hint') === '1';
|
||||||
mPanel.querySelector('.is-kin').hidden = !kin;
|
mPanel.querySelector('.is-kin').hidden = !kin;
|
||||||
mPanel.querySelector('form.is-self').hidden = kin;
|
mPanel.querySelector('form.is-self').hidden = kin;
|
||||||
if (!kin) {
|
if (!kin) {
|
||||||
mPanel.querySelector('form.is-self input[name="person_id"]').value = pid2;
|
mPanel.querySelector('form.is-self input[name="person_id"]').value =
|
||||||
|
mb.getAttribute('data-pm-open');
|
||||||
}
|
}
|
||||||
gPanel.hidden = true;
|
open(mPanel);
|
||||||
mPanel.hidden = false;
|
|
||||||
wrap.hidden = false;
|
|
||||||
place(mPanel, mb);
|
|
||||||
openFor = pid2;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 点浮层内部 → 不动
|
// ---- 关闭:只有遮罩 / ✕ / 取消 会关(不做"点哪儿都关")----
|
||||||
if (wrap.contains(t)) { return; }
|
if (t.closest('[data-rm-close]')) {
|
||||||
closeAll();
|
e.preventDefault();
|
||||||
}, true);
|
closeAll();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
document.addEventListener('keydown', function (e) {
|
document.addEventListener('keydown', function (e) {
|
||||||
if (e.key === 'Escape') { closeAll(); }
|
if (e.key === 'Escape') { closeAll(); }
|
||||||
});
|
});
|
||||||
window.addEventListener('resize', closeAll);
|
|
||||||
window.addEventListener('scroll', function () { if (openFor) { closeAll(); } }, true);
|
|
||||||
|
|
||||||
/* 选中即提交(与原来一样是"锦上添花":面板里那个「保存」按钮一直都在) */
|
/* 选中即提交(面板里那个「保存」按钮一直都在,这条只是顺手) */
|
||||||
document.addEventListener('change', function (e) {
|
document.addEventListener('change', function (e) {
|
||||||
var inp = e.target;
|
var inp = e.target;
|
||||||
if (!inp || inp.type !== 'radio' || inp.name !== 'group_id') { return; }
|
if (!inp || inp.type !== 'radio' || inp.name !== 'group_id') { return; }
|
||||||
|
|||||||
+94
-29
@@ -67,24 +67,53 @@ $shortKind = \App\Birthday::KINDS;
|
|||||||
<p class="motto-line">“<?= e((string)$p['slogan']) ?>”</p>
|
<p class="motto-line">“<?= e((string)$p['slogan']) ?>”</p>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
<div class="detail">
|
<?php /* 用户 2026-10-04 第 11 批 #3 原话:「名册详情页面,需要修改。现在左侧是一个头像,
|
||||||
<aside class="detail-photo">
|
下面都是空白的,太浪费空间了。应该这样,左上角是头像,头像右侧开始排放姓名什么的基本信息,
|
||||||
|
然后就是其他信息开始往下顺序排放。」
|
||||||
|
|
||||||
|
⚠️ 旧布局是 `.detail { grid-template-columns: 260px 1fr }` 两栏:
|
||||||
|
左栏(头像卡)只有 ~330px 高,右栏好几张卡片 → **头像下面空出一大片**。
|
||||||
|
现在改成:
|
||||||
|
· 顶部一张「名片卡」:头像(左上)+ 右侧姓名/称呼/性别/账号身份等基本信息;
|
||||||
|
· 名片卡**下方**,其余各块全宽依次往下排。
|
||||||
|
这样无论信息多少都不会留白。 */ ?>
|
||||||
|
<div class="pcard">
|
||||||
|
<div class="pcard-avatar">
|
||||||
<img src="<?= e(avatar_url($p['avatar'])) ?>" alt="">
|
<img src="<?= e(avatar_url($p['avatar'])) ?>" alt="">
|
||||||
<div class="name"><?= e($pShown) ?></div>
|
</div>
|
||||||
<?php if ($isMyNick): ?><div class="sub">本名 <?= e((string)$p['name']) ?></div><?php endif; ?>
|
<div class="pcard-main">
|
||||||
<div class="sub">
|
<div class="pcard-name">
|
||||||
<?php if ($role === 'admin' && $p['account_status'] === 'active'): ?>
|
<?= e($pShown) ?>
|
||||||
家庭管理员
|
<?php if ($isMyNick): ?><span class="pcard-real">本名 <?= e((string)$p['name']) ?></span><?php endif; ?>
|
||||||
<?php elseif ($role === 'guest'): ?>
|
<?php if (!empty($p['is_placeholder'])): ?><span class="badge">虚拟人物</span><?php endif; ?>
|
||||||
访客(只读)
|
</div>
|
||||||
<?php elseif (!empty($p['account_id'])): ?>
|
<div class="pcard-meta">
|
||||||
已开通登录<?= $p['account_status'] === 'disabled' ? '(已停用)' : '' ?>
|
<?php if (!$isSelf && (string)$labelToMe !== ''): ?>
|
||||||
<?php else: ?>
|
<span class="rel-label"><?= e($labelToMe) ?></span>
|
||||||
还没有开通登录
|
<?php endif; ?>
|
||||||
|
<span class="pcard-chip">
|
||||||
|
<?php if ($role === 'admin' && $p['account_status'] === 'active'): ?>
|
||||||
|
<b>家庭管理员</b>
|
||||||
|
<?php elseif ($role === 'guest'): ?>
|
||||||
|
访客(只读)
|
||||||
|
<?php elseif (!empty($p['account_id'])): ?>
|
||||||
|
已开通登录<?= $p['account_status'] === 'disabled' ? '(已停用)' : '' ?>
|
||||||
|
<?php else: ?>
|
||||||
|
还没有开通登录
|
||||||
|
<?php endif; ?>
|
||||||
|
</span>
|
||||||
|
<span class="pcard-chip"><?= e(\App\Person::genderWord((string)($p['gender'] ?? '')) ?: '性别未填') ?></span>
|
||||||
|
<?php if (!empty($p['birth_rank'])): ?>
|
||||||
|
<span class="pcard-chip">第 <?= (int)$p['birth_rank'] ?> 个孩子</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if ($age !== null): ?>
|
||||||
|
<span class="pcard-chip"><?= (int)$age ?> 岁</span>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="detail-stack">
|
||||||
<div>
|
<div>
|
||||||
<div class="card card-pad">
|
<div class="card card-pad">
|
||||||
<dl class="kv">
|
<dl class="kv">
|
||||||
@@ -593,7 +622,7 @@ $shortKind = \App\Birthday::KINDS;
|
|||||||
</div>
|
</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div><?php /* /detail-stack —— 名片卡下方的全宽堆叠到此结束 */ ?>
|
||||||
|
|
||||||
<?php /* ==================== 危险操作:删除(用户 2026-10-03 第 2 条)====================
|
<?php /* ==================== 危险操作:删除(用户 2026-10-03 第 2 条)====================
|
||||||
「删除」从右上角搬到了页面**最下方**、「开通登录账号」下面。
|
「删除」从右上角搬到了页面**最下方**、「开通登录账号」下面。
|
||||||
@@ -602,26 +631,62 @@ $shortKind = \App\Birthday::KINDS;
|
|||||||
原来那句"从名册里移除"听着像"只是从我的名册里移走",
|
原来那句"从名册里移除"听着像"只是从我的名册里移走",
|
||||||
实际会删账号与亲属关系,说法与后果不符。 */ ?>
|
实际会删账号与亲属关系,说法与后果不符。 */ ?>
|
||||||
<?php if ($admin): ?>
|
<?php if ($admin): ?>
|
||||||
|
<?php /* 用户 2026-10-04 第 11 批 #2 原话:「个人详情页,最下面的删除,竟然是个链接,
|
||||||
|
应该是个按钮,跟开通账号一样,是个大按钮,只不过背景用红色。」
|
||||||
|
→ 从 <details> + 文字链接 + 小按钮,改成**与「开通登录账号」同规格的大按钮**,
|
||||||
|
只把底色换成红色。确认仍保留(破坏性操作),但改成弹框式的二次确认,
|
||||||
|
不再用浏览器原生 confirm(那玩意儿在不同浏览器里长相差异大)。 */ ?>
|
||||||
<div class="danger-zone">
|
<div class="danger-zone">
|
||||||
<details class="del-wrap">
|
<div class="del-big">
|
||||||
<summary class="link-quiet">删除这个人…</summary>
|
<p class="del-big-tip">
|
||||||
<div class="del-confirm">
|
删除会把他从名册中移除,<b>并且</b>删掉登录账号与全部亲属关系连线,<b>不可恢复</b>。<br>
|
||||||
<p class="small muted" style="margin:0 0 8px;flex:1 1 100%">
|
如果你只是想让他从<b>你的</b>名册里消失,用名册列表上的「⋯ → 从我的名册移走」。
|
||||||
会把他从名册中删除,**并且**删掉他的登录账号与全部亲属关系连线(不可恢复)。
|
</p>
|
||||||
如果你只是想让他从<b>你的</b>名册里消失,用名册列表上的「⋯ → 从我的名册移走」。
|
<button type="button" class="btn btn-danger btn-block-lg" id="delBigBtn">
|
||||||
</p>
|
删除 <?= e((string)$p['name']) ?>
|
||||||
<form method="post" action="<?= e(url('people/destroy')) ?>"
|
</button>
|
||||||
onsubmit="return confirm('确定要删除 <?= e($p['name']) ?> 吗?账号与亲属关系会一起删掉,无法恢复。')">
|
<form method="post" action="<?= e(url('people/destroy')) ?>" id="delBigForm" hidden>
|
||||||
<?= csrf_field() ?>
|
<?= csrf_field() ?>
|
||||||
<input type="hidden" name="id" value="<?= $pid ?>">
|
<input type="hidden" name="id" value="<?= $pid ?>">
|
||||||
<button class="btn btn-danger btn-sm" type="submit">确认删除</button>
|
</form>
|
||||||
</form>
|
<div class="del-big-confirm" id="delBigConfirm" hidden>
|
||||||
|
<span>确定删除?此操作无法撤销。</span>
|
||||||
|
<button class="btn btn-danger" type="button" id="delBigYes">确认删除</button>
|
||||||
|
<button class="btn btn-ghost" type="button" id="delBigNo">取消</button>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
/* 大删除按钮的两步确认(用户第 11 批 #2):第一次点露出"确认删除/取消",
|
||||||
|
第二次点才真的提交。比原生 confirm 可控,也与页面其它确认长得一致。 */
|
||||||
|
(function () {
|
||||||
|
var btn = document.getElementById('delBigBtn');
|
||||||
|
var box = document.getElementById('delBigConfirm');
|
||||||
|
var form = document.getElementById('delBigForm');
|
||||||
|
if (!btn || !box || !form) { return; }
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
box.hidden = false;
|
||||||
|
btn.hidden = true;
|
||||||
|
});
|
||||||
|
var no = document.getElementById('delBigNo');
|
||||||
|
if (no) {
|
||||||
|
no.addEventListener('click', function () {
|
||||||
|
box.hidden = true;
|
||||||
|
btn.hidden = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
var yes = document.getElementById('delBigYes');
|
||||||
|
if (yes) {
|
||||||
|
yes.addEventListener('click', function () {
|
||||||
|
yes.disabled = true;
|
||||||
|
yes.textContent = '正在删除…';
|
||||||
|
form.submit();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
// 关系表单的「是哪一位」下拉有**两种含义**:
|
// 关系表单的「是哪一位」下拉有**两种含义**:
|
||||||
// kind=parent → 对方是父亲/母亲(填的是对方)
|
// kind=parent → 对方是父亲/母亲(填的是对方)
|
||||||
// kind=child → 我是孩子的父亲/母亲(填的是自己)
|
// kind=child → 我是孩子的父亲/母亲(填的是自己)
|
||||||
|
|||||||
Reference in New Issue
Block a user