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:
2026-10-04 17:42:21 +08:00
parent b2ca4f336e
commit 5d80b9480b
17 changed files with 1592 additions and 637 deletions
+286 -60
View File
@@ -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 .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-row {
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 人 =
2369 个 form / 27234 个 input,密码管理器逐个扫描把页面拖死)。
这里给共享浮层一个定位容器,面板由 JS 贴合触发按钮摆放。 */
.roster-menus { position: fixed; inset: 0; z-index: 60; pointer-events: none; }
.roster-menus .rm-panel {
position: fixed; pointer-events: auto;
/* ---- v24 第 11 批 #2:名册卡片的动作**居中模态框** ----
(第 10 批是"贴按钮的浮层",用户反馈分组"点第二次失效"且要求"像选观影人一样弹框",
于是改成遮罩 + 居中面板,与 _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);
border-radius: 10px; box-shadow: var(--shadow-l);
padding: 8px 0; min-width: 200px;
border-radius: 14px; box-shadow: var(--shadow-l);
width: min(360px, 92vw); max-height: min(78vh, 620px);
display: flex; flex-direction: column;
}
.roster-menus .rm-panel .person-move { padding: 0 10px; }
.roster-menus .rm-panel .person-move.is-self { padding: 0 12px; }
.roster-menus[hidden] { display: none; }
.rm-panel[hidden] { display: none; }
.rm-head {
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 .select { flex: 1 1 auto; min-width: 0; padding: 6px 8px; font-size: 13px; }
.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.is-open {
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);
}
.crop-panel {
background: var(--surface); border-radius: var(--radius);
background: var(--surface); border-radius: 14px;
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 {
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-stage {
position: relative; margin: 0 auto; overflow: auto;
background: #F1E7D6; border-radius: var(--radius-s);
touch-action: none; /* 手机上拖框时别把页面一起滚走 */
user-select: none;
.crop-x {
margin-left: auto; align-self: center; border: 0; background: transparent;
font-size: 15px; line-height: 1; color: var(--muted);
cursor: pointer; padding: 4px 6px; border-radius: 6px;
}
/* 图片的宽高由 JS 按倍率写死(放大后比舞台大,靠舞台自身滚动看别处)——
所以这里**不能**写 width/height:100%,否则缩放就永远没效果。 */
.crop-img { display: block; max-width: none; }
/* 缩放条 —— 用户明确要求"裁剪的时候要能缩放" */
.crop-zoom-row {
display: flex; align-items: center; gap: 10px;
margin-top: 12px; font-size: 13px; color: var(--muted);
.crop-x:hover { background: var(--line-2); color: var(--ink); }
/* 画布:**固定尺寸**(不是 max-height),这样 Cropper 一定会把图片缩进来,
而不会让容器去滚动。宽高都换算成"视口内还能放下的最大方块"。
⚠️ 留 22px 内边距是给裁剪框四个角的手柄留位(要求④)。 */
.crop-canvas {
flex: 0 0 auto; position: relative;
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-zoom-val { font-variant-numeric: tabular-nums; min-width: 42px; text-align: right; }
/* 框外的部分压暗 —— 这是"你只留下框里这块"最直观的表达 */
.crop-box {
position: absolute; cursor: move;
border: 2px solid #fff;
box-shadow: 0 0 0 9999px rgba(44, 35, 29, .5);
.crop-canvas img { display: block; max-width: 100%; }
.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; }
/* ④ 触点全露出:Cropper 默认的手柄太细、颜色偏灰,在深色画布上不好抓。
这里加粗、改成白底品牌边,并**保证它们不被画布裁掉**(Cropper 自己会留 margin)。 */
.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 {
content: ''; position: absolute; inset: 0;
border: 1px dashed rgba(255, 255, 255, .75); pointer-events: none;
}
.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; }
.cropper-point.point-se { width: 14px; height: 14px; }
.cropper-bg { background-image: none; background-color: #2C231D; }
.cropper-modal { background-color: rgba(20, 14, 10, .55); }
@media (max-width: 720px) {
.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-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:总金额图在最前(跨整行),下面才是逐条账目 ---- */
.ck-total { margin-bottom: 14px; }
.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-what { flex: 1 1 auto; min-width: 0; font-size: 14px; }
.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) {
.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;
}
/* ---- v24 第 10 批 #6:日程页头的「跳到某一天」按钮 ----
外观与 .btn 一致(它是 <label> 包着一个原生 date 输入,所以自己画外壳)。
原生日历指示器收掉:整个按钮都是可点的,多一个小图标反而抢视线。 */
/* ---- 日程页头的「跳到某一天」(v24 第 11 批 #6 重做)----
用户要求:「跳转日期那个地方,不要那么长,只需要和设置一样,一个按钮即可」,
且日期选择要用 Semi(与全站统一)。
所以现在是**一个方图标按钮**(与 ☰ 的 .btn-icon 同规格):
点开弹一个只含 Semi 日期选择器的轻浮层,选中即跳。 */
.cal-jump { display: inline-flex; margin: 0; }
.cal-jump-btn {
display: inline-flex; align-items: center; gap: 6px;
cursor: pointer; white-space: nowrap;
.cal-jump-btn { display: inline-flex; align-items: center; justify-content: center; cursor: pointer; }
.cal-jump-host { position: fixed; inset: 0; z-index: 80; display: flex;
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-txt { font-size: 13px; }
.cal-jump-date {
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-pop-head {
display: flex; align-items: center; gap: 10px;
font-size: 13.5px; font-weight: 600; margin-bottom: 8px;
}
.cal-jump-date::-webkit-calendar-picker-indicator { cursor: pointer; opacity: .55; }
.cal-jump-date:focus { outline: none; }
@media (max-width: 640px) {
/* 窄屏:只留图标 + 日期,把「跳到」两个字收掉(不然页头一排按钮换行) */
.cal-jump-txt { display: none; }
.cal-jump-date { min-width: 104px; }
.cal-jump-x {
margin-left: auto; border: 0; background: transparent; cursor: pointer;
font-size: 14px; color: var(--muted); padding: 3px 6px; border-radius: 6px;
}
.cal-jump-x:hover { background: var(--line-2); color: var(--ink); }
/* ==================== 周 / 天:按小时铺的时间网格 ==================== */
/* 结构:左边一条"小时刻度"沟槽(tg-gutter),右边 N 个日期列(tg-col)。
+232 -259
View File
@@ -26,308 +26,278 @@
for (var i = 0; i < pickers.length; i++) { hook(pickers[i]); }
})();
// ===== 图片裁剪 =====
// 比例由**这个位置显示成什么形状**决定(头像 1:1、题图 16:9),
// 由服务端通过 data-crop-ratio 告诉这里 —— 前端不再自己定一个数字。
// ===== 图片裁剪(v24 第 11 批 #5:改用 Cropper.js)=====
//
// 缩放(用户明确要求「裁剪图片的时候,需要可以进行缩放」):
// 舞台是个**固定大小的视口**(baseW×baseH),图片按 zoom 放大到 baseW*zoom,
// 放不下就由舞台自己滚动。裁剪框的坐标一律相对**显示出来的图片**算,
// 最后除以 dispW/dispH 换成 0..1 —— 所以放大多少倍都不影响落库的那个矩形。
// 框的最小边长固定 32 显示像素,于是放大之后能选中原图上更小的区域,
// 这正是"缩放"要解决的问题(合影里挑出某一个人的脸)。
// 用户原话:「所有上传图片带有裁剪功能的地方,都需要重做,现在仍然不好用。
// 用 Cropper.js 这个库,弹框不要有任何滚动条,图片就无论多大,
// 就缩放到这个框内,也不要缩放条,鼠标滚轮滚动后,缩放大小,
// 裁剪框,要比外面的弹框小,让所有的裁剪触点都露出来。」
//
// ⚠️ 这里只是「让用户挑区域」,真正裁切在服务端做(见 app/Upload.php):
// 前端算出来的框只是**建议**,绕过前端也不会存进一张比例不对的图。
// 所以确认时写回的是**归一化矩形**(相对原图的 0..1),不是像素。
// 逐条落地:
// · **弹框不许有滚动条** → 面板 overflow:hidden;图片用 `img { max-width:100% }`
// 交给 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 () {
var inputs = Array.prototype.slice.call(
document.querySelectorAll('input[type="file"][data-crop-ratio]'));
if (!inputs.length) { return; }
var overlay = null, stage = null, imgEl = null, boxEl = null;
var st = null; // 当前编辑状态
var meta = document.querySelector('meta[name="fb-cropper"]');
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:1 的细长条,而钩子本身不报错、只是裁出来的图没法看。
* 头像的 '1:1' 恰好蒙对(=1),所以这个坑会潜伏到"启用题图"那天才炸。
* 两种写法都支持:'16:9' / '16/9' 按分数算,纯数字按它本身算。
* ⚠️ 必须支持 **"16:9" 这种带冒号的写法**:`parseFloat('16:9')` 会得到 **16**
* (它遇到 ':' 就停),于是"题图"槽位会裁出一个 16:1 的细长条 ——
* 页面不报错、图也存下去了,只有肉眼看才发现不对。
* (旧自研版正因为同一处栽过,交互测试里专门留了一条断言守它。)
*/
function parseRatioAttr(v) {
var s = String(v == null ? '' : v).trim();
var m = /^(\d+(?:\.\d+)?)\s*[:/]\s*(\d+(?:\.\d+)?)$/.exec(s);
var t = String(v == null ? '' : v).trim();
if (t === '') { return 1; }
var m = t.match(/^(\d+(?:\.\d+)?)\s*[:/x×]\s*(\d+(?:\.\d+)?)$/);
if (m) {
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);
return (isFinite(f) && f > 0) ? f : 1;
var n = parseFloat(t);
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() {
overlay = document.createElement('div');
overlay.className = 'crop-overlay';
overlay.innerHTML =
'<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-stage">' +
'<img class="crop-img" alt="" draggable="false">' +
'<div class="crop-box"><i class="crop-handle" aria-hidden="true"></i></div>' +
'</div>' +
'<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-head"><b>裁剪图片</b><span class="crop-shape"></span>' +
'<button type="button" class="crop-x" aria-label="关闭">✕</button></div>' +
// ⚠️ 这里**不再有** stage / 缩放条 / 自画框:全部交给 Cropper.js。
'<div class="crop-canvas"><img class="crop-img" alt=""></div>' +
'<p class="crop-tip"></p>' +
'<div class="crop-actions">' +
'<button type="button" class="btn btn-ghost" data-act="cancel">取消</button>' +
'<button type="button" class="btn" data-act="ok">确定裁剪</button>' +
'<button type="button" class="btn btn-ghost crop-cancel">取消</button>' +
'<button type="button" class="btn crop-ok">就用这块</button>' +
'</div>' +
'</div>';
document.body.appendChild(overlay);
stage = overlay.querySelector('.crop-stage');
imgEl = overlay.querySelector('.crop-img');
boxEl = overlay.querySelector('.crop-box');
panel = overlay.querySelector('.crop-panel');
imgEl = overlay.querySelector('.crop-img');
hintEl = overlay.querySelector('.crop-tip');
overlay.addEventListener('click', function (e) {
if (e.target === overlay) { close(false); return; }
var act = e.target.getAttribute && e.target.getAttribute('data-act');
if (act === 'cancel') { close(false); }
if (act === 'ok') { close(true); }
if (act === 'zoom-reset') { setZoom(1); }
});
overlay.querySelector('.crop-x').addEventListener('click', function () { close(true); });
overlay.querySelector('.crop-cancel').addEventListener('click', function () { close(true); });
overlay.addEventListener('click', function (e) { if (e.target === overlay) { close(true); } });
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); }
});
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';
overlay.querySelector('.crop-ok').addEventListener('click', confirm);
}
/**
* 改缩放。
* 关闭浮层。
*
* 关键:**框在屏幕上的位置和大小都不动**,动的是它底下那张图 ——
* 放大=同一块区域看得更清楚;缩小=框进更多内容。
* (2026-09-30 用户反馈:之前的做法是"归一化区域不变",结果框跟着图一起变大,
* 缩放几下框就大得找不到了。)
* 落库的归一化矩形由 place() 夹取后自动重算 —— 缩放改变选中的区域,这是本意。
* @param {boolean} cancelled true = 用户**取消**(点 ✕ / 取消 / 遮罩 / Esc)。
*
* ⚠️ 取消时必须把 file input 清空:否则用户以为"没选图",实际上表单里
* 仍挂着那张**没裁过的原图**,一提交就传上去了(旧自研版有这一步,
* 换成 Cropper.js 时被我漏掉,靠交互测试的"取消后文件框被清空"抓回来)。
* 确认(confirm)走的是另一条路 —— 那时文件正是用户要传的,不能清。
*/
function setZoom(z) {
if (!st) { return; }
z = Math.max(1, Math.min(MAX_ZOOM, z));
var b = { x: boxEl.offsetLeft, y: boxEl.offsetTop, w: boxEl.offsetWidth, h: boxEl.offsetHeight };
st.zoom = z;
st.dispW = Math.max(1, Math.round(st.baseW * z));
st.dispH = Math.max(1, Math.round(st.baseH * z));
function close(cancelled) {
if (cropper) { cropper.destroy(); cropper = null; }
if (overlay) { overlay.classList.remove('is-open'); }
if (objUrl) { URL.revokeObjectURL(objUrl); objUrl = ''; }
if (cancelled && cur) {
try { cur.value = ''; } catch (e) { /* 某些浏览器对 file.value 只读,忽略 */ }
// 顺带把已经写进去的裁剪矩形也清掉,避免"上次的框"跟着下一次提交
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';
stage.style.height = st.baseH + 'px';
/**
* 对外暴露当前「图片显示尺寸 + 裁剪框」,**只给测试用**。
*
* ⚠️ 存在的理由:验证"滚轮真的缩放了"不能只看"有没有 wheel 事件"——
* 那证明不了库真的动了图片。有了这个探针,测试可以断言
* "滚轮前后 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');
if (zoomEl) { zoomEl.value = String(z); }
var valEl = overlay.querySelector('.crop-zoom-val');
if (valEl) { valEl.textContent = z.toFixed(1) + '×'; }
// 框保持屏显大小;位置也保持(place 只做越界夹取,缩小到 1× 时兜底用)
place(b.x, b.y, b.w, b.h);
function confirm() {
if (!cropper || !cur) { close(); return; }
var d = cropper.getData(true); // true = 取整
var img = cropper.getImageData();
var nw = img.naturalWidth || 1;
var nh = img.naturalHeight || 1;
// 归一化:服务端要的就是"相对原图的 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) {
cur = input;
if (!overlay) { build(); }
var ratio = parseRatioAttr(input.getAttribute('data-crop-ratio'));
var url = URL.createObjectURL(file);
var probe = new Image();
probe.onload = function () {
// 图片按「等比缩放到可用区域」后显示;裁剪框的坐标都相对这块显示区域,
// 最后再除以它换成 0..1 —— 所以原图多大都不影响结果。
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));
var label = input.getAttribute('data-crop-label') || '图片';
var hint = input.getAttribute('data-crop-hint') || '';
overlay.querySelector('.crop-shape').textContent =
label + (ratio === 1 ? '(正方形)' : '(' + ratio.toFixed(2) + ':1)');
hintEl.textContent = hint !== '' ? hint
: '拖动图片挪位置,滚轮缩放;虚线框里就是最终保存的样子。';
st = { input: input, ratio: ratio,
baseW: baseW, baseH: baseH, zoom: 1,
dispW: baseW, dispH: baseH,
naturalW: probe.naturalWidth, naturalH: probe.naturalHeight,
previewSrc: null, outW: parseInt(input.getAttribute('data-crop-w'), 10) || baseW,
outH: parseInt(input.getAttribute('data-crop-h'), 10) || baseH };
if (objUrl) { URL.revokeObjectURL(objUrl); }
objUrl = URL.createObjectURL(file);
imgEl.src = objUrl;
overlay.classList.add('is-open');
imgEl.src = url;
imgEl.style.width = baseW + 'px';
imgEl.style.height = baseH + 'px';
stage.style.width = baseW + 'px';
stage.style.height = baseH + 'px';
stage.scrollLeft = 0;
stage.scrollTop = 0;
// ⚠️ 倍率每次打开都从 1× 开始:上一张图缩到 3× 不该带到这一张上,
// 否则新图的框会莫名其妙是上一张的"放大版"。
var zoomEl = overlay.querySelector('.crop-zoom');
if (zoomEl) { zoomEl.value = '1'; }
var valEl = overlay.querySelector('.crop-zoom-val');
if (valEl) { valEl.textContent = '1.0×'; }
var shape = overlay.querySelector('.crop-shape');
shape.textContent = input.getAttribute('data-crop-label') || '';
overlay.querySelector('.crop-tip').textContent =
input.getAttribute('data-crop-hint') ||
'拖动方框挪位置,拖右下角改大小;放不下就用「缩放」放大再看细节。框里就是最终会保存的样子。';
// 默认框 = 图里最大的、符合目标比例的那块(居中)—— 什么都不动直接确定,
// 效果就等于老的「居中裁切」,所以默认值是安全的。
var w = baseW, h = w / ratio;
if (h > baseH) { h = baseH; w = h * ratio; }
place((baseW - w) / 2, (baseH - h) / 2, w, h);
// 记住预览图原来的 src:取消时恢复,否则预览会停在"没裁过的图"上
var prev = preview(input);
st.previewSrc = prev ? prev.src : null;
st._url = url;
overlay.classList.add('is-open');
};
probe.onerror = function () {
URL.revokeObjectURL(url);
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);
ensureCropper().then(function () {
if (cropper) { cropper.destroy(); cropper = null; }
cropper = new window.Cropper(imgEl, {
// ★ 比例由槽位决定(头像 1:1、题图 16:9…),Cropper 会据此锁定缩放方向。
// ⚠️ 少了它,框能自由拉伸,"题图"就会被裁成任意比例。
aspectRatio: ratio,
// ① 框不能超出画布,且图片自动缩放铺进容器(**不产生滚动条**)
viewMode: 1,
// ② 图片可以比"容器"小或大,但始终被约束在画布内
dragMode: 'move',
// ③ 滚轮缩放(取代原来那根滑动条)
zoomOnWheel: true,
wheelZoomRatio: 0.06,
// ④ 不许把图片缩到看不见 / 放到糊
minContainerWidth: 200, minContainerHeight: 200,
minCropBoxWidth: 32, minCropBoxHeight: 32,
// ⑤ 触点要**全露出来**:框四周始终留出可抓的边(见 CSS .cropper-* 手柄)
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,
toggleDragModeOnDblclick: false,
});
// 只给测试用:把实例露出去,便于用库 API 确定性地改变框尺寸
// (无头浏览器里 5×5 的触点很难稳定命中,靠鼠标拖会变成"测不稳")。
window.__cropperInst = cropper;
}).catch(function (err) {
// 拉不到库 → 退回"不裁剪":清空隐藏字段(服务端会**居中裁切**),
// 并明确告诉用户,而不是默默地不裁。
if (window.console) { console.warn('Cropper.js 未加载,改为中心裁切:', err && err.message); }
hintEl.textContent = '裁剪组件没加载出来 —— 保存时会自动居中裁切。';
var f = input.form || document;
var hid = f.querySelector('input[type="hidden"][name$="_crop"]');
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');
host.className = 'dt-semi dt-semi-range';
// 插到这一对控件的**前面**(原生那对随后藏起来)
group.insertBefore(host, group.firstChild);
// ⚠️ 插到**第一个原生控件之前**,不能插到 group 的最前面。
// 因为 group 就是那个 `.field`,`<label>` 也是它的子节点:
// 用 group.firstChild 会把控件塞到 label **上面**(第一版就是这么写的),
// 用户看到的现象正是"开始/结束时间这几个字跑到日期控件下面去了"。
group.insertBefore(host, a);
var fmt = 'yyyy-MM-dd HH:mm' + (secs ? ':ss' : '');
+119 -14
View File
@@ -79,21 +79,30 @@
// `hy` + `yf` = `hyyf`,子序列 `yy` 正好跨过边界命中;
// 单独看 `hy` 与 `yf` 都不含 `yy`。逐字段判才是对的。
// 服务端(App\Pinyin::matches)本来就是逐字段判的,两边口径保持一致。
Array.prototype.forEach.call(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()); }
/**
* 给一批 .pp-item 算 data-initials 与首字母徽标。
* ⚠️ 抽成函数是因为**动态建出来的清单也要走同一套**(v24 第 11 批 #7 续:
* 候选人清单改成打开弹框时才建,建完必须补算首字母,
* 否则"输入 yyq 找杨永全"这条既有功能会静默失效)。
*/
function decorateItems(scope) {
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(' '));
// 徽标只显示**姓名的**首字母(称呼那串太杂,显示出来反而更乱)
var badge = el.querySelector('.pp-initials');
var first = initialsOf ? initialsOf(n1) : '';
if (badge && first) { badge.textContent = first; badge.hidden = false; }
});
}
decorateItems();
function syncChips() {
chips.innerHTML = '';
@@ -159,8 +168,64 @@
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() {
modal.hidden = false;
buildList();
search.value = '';
applyFilter();
syncChips();
@@ -196,8 +261,48 @@
});
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() {
Array.prototype.forEach.call(document.querySelectorAll('.pp'), init);
}
+9
View File
@@ -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}
File diff suppressed because one or more lines are too long