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:
+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 .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
@@ -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
@@ -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
@@ -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
Reference in New Issue
Block a user