v1.2.71:修复后台 CDK 复制无效;用户管理执行弹窗状态标签换行
ci/woodpecker/tag/build Pipeline was successful

① 用户管理「当日执行」弹窗:状态列的「成功/失败」与「已删除」两个标签原在同一行
   (td 带 whitespace-nowrap,列宽 w-[60px]),在窄列下被挤到溢出、视觉上串到
   「说明」列。改为纵向排列(flex-col + gap-1,各占一行),表头宽度放宽到 w-[72px]。

② CDK 管理复制「提示成功但实际粘贴不出内容」:
   根因是 copyToClipboard 把临时 textarea 挂到 document.body,而复制按钮位于
   AppDialog(reka-ui)弹窗内——DialogContent 用 FocusScope 锁定焦点,document 上的
   focusin 监听发现焦点跑到 dialog 容器外就立即抢回,导致 ta.focus()/select() 失效,
   选区为空;execCommand('copy') 仍返回 true,于是「显示已复制、实际为空」。
   修复:
   - textarea 优先挂到当前打开的 dialog 容器内(role="dialog" 中最靠后可见者),
     避免焦点被 FocusScope 抢回;
   - 复制前校验选区确实落在 textarea 上(selectionStart/End 与 activeElement),
     校验不过则不再盲信 execCommand 的返回值,转而走 Clipboard API 兜底;
   - 移除 textarea 改为按实际父节点(不再假设是 body)。
   该修复同时惠及所有位于弹窗内的复制按钮(CDK 单个/全部复制、待审学校等)。

   注:AdminUsers 的设备码复制(copyDeviceCode)未在本次范围内,保持原样。
This commit is contained in:
becomer
2026-09-19 10:38:42 +08:00
parent 3bcc32b74e
commit c5e0888639
2 changed files with 47 additions and 21 deletions
+30 -6
View File
@@ -6,8 +6,25 @@
* 若先 await Clipboard API 被拒(移动端 WebView 常见 NotAllowedError), * 若先 await Clipboard API 被拒(移动端 WebView 常见 NotAllowedError),
* 手势上下文已丢失,后续 execCommand 会静默失败(返回 false 但剪贴板无内容)。 * 手势上下文已丢失,后续 execCommand 会静默失败(返回 false 但剪贴板无内容)。
* *
* 弹窗内复制的坑:AppDialog(reka-ui)会用 FocusScope 锁定焦点——
* document 上的 focusin 监听发现焦点跑到 dialog 容器之外时会立即抢回。
* 若把临时 textarea 挂到 document.body(容器外),ta.focus() 会立刻被抢走,
* 选区失效,execCommand('copy') 仍返回 true 但复制的是空内容
*(表现为「提示已复制,实际粘贴不出东西」,且只发生在弹窗内的复制按钮)。
* 因此:优先挂到当前打开的 dialog 容器内;并显式校验选区是否真的取到文本。
*
* 返回是否复制成功 * 返回是否复制成功
*/ */
function findActiveDialogContainer(): HTMLElement | null {
const dialogs = document.querySelectorAll<HTMLElement>('[role="dialog"]')
// 取最后一次渲染(DOM 中靠后)且可见的那个
for (let i = dialogs.length - 1; i >= 0; i--) {
const el = dialogs[i]
if (el && el.offsetParent !== null) return el
}
return dialogs.length > 0 ? dialogs[dialogs.length - 1] : null
}
export async function copyToClipboard(text: string): Promise<boolean> { export async function copyToClipboard(text: string): Promise<boolean> {
// 1) 同步路径:隐藏 textarea + execCommand(用户手势内,兼容性最好) // 1) 同步路径:隐藏 textarea + execCommand(用户手势内,兼容性最好)
const ta = document.createElement('textarea') const ta = document.createElement('textarea')
@@ -16,17 +33,24 @@ export async function copyToClipboard(text: string): Promise<boolean> {
// font-size ≥ 16px 避免 iOS focus 时触发页面缩放 // font-size ≥ 16px 避免 iOS focus 时触发页面缩放
ta.style.cssText = 'position:fixed;top:0;left:0;width:1px;height:1px;opacity:0;font-size:16px;border:0;padding:0;margin:0;' ta.style.cssText = 'position:fixed;top:0;left:0;width:1px;height:1px;opacity:0;font-size:16px;border:0;padding:0;margin:0;'
ta.setAttribute('readonly', '') ta.setAttribute('readonly', '')
document.body.appendChild(ta) // 关键:挂到 dialog 容器内(若有弹窗打开),否则焦点会被 FocusScope 抢回导致为空复制
ta.focus() const host: HTMLElement = findActiveDialogContainer() || document.body
ta.select() host.appendChild(ta)
ta.setSelectionRange(0, text.length)
let ok = false let ok = false
try { try {
ok = document.execCommand('copy') ta.focus()
ta.select()
ta.setSelectionRange(0, text.length)
// 校验选区确实落在本 textarea 上(焦点被抢时 selectionStart/End 会异常)
const selectionValid =
ta.selectionStart === 0 && ta.selectionEnd === text.length && document.activeElement === ta
if (selectionValid) {
ok = document.execCommand('copy')
}
} catch { } catch {
ok = false ok = false
} }
document.body.removeChild(ta) if (ta.parentNode) ta.parentNode.removeChild(ta)
if (ok) return true if (ok) return true
// 2) 异步回退:Clipboard API(仅 HTTPS/localhost;注意此时手势可能已丢失,仅作兜底) // 2) 异步回退:Clipboard API(仅 HTTPS/localhost;注意此时手势可能已丢失,仅作兜底)
+17 -15
View File
@@ -868,7 +868,7 @@ function todayStatusLabel(s: string) {
<th class="px-4 py-2.5 font-medium w-[130px]">用户</th> <th class="px-4 py-2.5 font-medium w-[130px]">用户</th>
<th class="px-4 py-2.5 font-medium w-[64px]">类型</th> <th class="px-4 py-2.5 font-medium w-[64px]">类型</th>
<th class="px-4 py-2.5 font-medium">任务标题</th> <th class="px-4 py-2.5 font-medium">任务标题</th>
<th class="px-4 py-2.5 font-medium w-[60px]">状态</th> <th class="px-4 py-2.5 font-medium w-[72px]">状态</th>
<th class="px-4 py-2.5 font-medium">说明</th> <th class="px-4 py-2.5 font-medium">说明</th>
</tr> </tr>
</thead> </thead>
@@ -891,20 +891,22 @@ function todayStatusLabel(s: string) {
<td class="px-4 py-2.5 truncate text-gray-700" :title="log.realTitle || log.title"> <td class="px-4 py-2.5 truncate text-gray-700" :title="log.realTitle || log.title">
{{ log.realTitle || log.title || '--' }} {{ log.realTitle || log.title || '--' }}
</td> </td>
<td class="px-4 py-2.5 whitespace-nowrap"> <td class="px-4 py-2.5">
<span <div class="flex flex-col items-start gap-1">
class="text-[10px] px-1.5 py-0.5 rounded-full font-medium inline-block" <span
:class="log.isSuccess ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'" class="text-[10px] px-1.5 py-0.5 rounded-full font-medium inline-block"
> :class="log.isSuccess ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'"
{{ log.isSuccess ? '成功' : '失败' }} >
</span> {{ log.isSuccess ? '成功' : '失败' }}
<span </span>
v-if="log.deleted" <span
class="text-[10px] px-1.5 py-0.5 rounded-full font-medium inline-block ml-1 bg-gray-100 text-gray-400 border border-gray-200" v-if="log.deleted"
title="用户已在客户端删除该日志,后台统计仍计入" class="text-[10px] px-1.5 py-0.5 rounded-full font-medium inline-block bg-gray-100 text-gray-400 border border-gray-200"
> title="用户已在客户端删除该日志,后台统计仍计入"
已删除 >
</span> 已删除
</span>
</div>
</td> </td>
<td class="px-4 py-2.5"> <td class="px-4 py-2.5">
<span v-if="log.errorMessage" class="text-xs text-red-500 break-words">{{ log.errorMessage }}</span> <span v-if="log.errorMessage" class="text-xs text-red-500 break-words">{{ log.errorMessage }}</span>