mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 11:13:43 +08:00
fix(channels): close QR loading dead-window for feishu and dingtalk register flows
Reported issue: click 'scan to create' -> button momentarily flickers
loading -> button re-enables but no QR shows up -> blank for 1-2 seconds
-> QR suddenly appears. Looks broken even though it works.
Root cause: loading.value flipped back to false the moment the begin HTTP
call returned (sessionId in hand), but the actual QR image only arrives on
the first status poll, which the existing code waited a full 2 seconds
for. Between begin completing and the first poll firing the UI was a
disabled button + nothing.
Three coordinated changes:
- useFeishuAppRegister and useDingTalkAppRegister: keep loading.value true
through begin AND across the polls, only flip false when the QR image
is actually populated (or a terminal failure status arrives). Also run
an immediate first poll right after begin instead of waiting for the
setInterval tick — usually the first poll already has the rendered QR
for dingtalk, and pushes the feishu user roughly 2 seconds closer.
- ChannelEditModal: same-sized loading placeholder (min-height 240px,
matching the QR card) that renders when loading is true and no QR is
in hand. CSS spinner ring tinted with the channel brand color (feishu
indigo, dingtalk blue) and a new
channels.{feishu,dingtalk}Register.qrcodeLoading hint. The placeholder
swaps to the real image with no layout shift.
- i18n: new qrcodeLoading key in zh-CN and en-US for both flows.
Net effect: click to spinner-visible is ~50ms; the user is never staring
at a frozen button-without-content again.
This commit is contained in:
parent
5bef83a156
commit
b4aef56c89
@ -138,7 +138,12 @@
|
||||
? t('channels.dingtalkRegister.buttonLoading')
|
||||
: t('channels.dingtalkRegister.button') }}
|
||||
</button>
|
||||
<div v-if="dingtalkRegister.qrcodeUrl.value" class="dingtalk-register-qrcode">
|
||||
<!-- Loading placeholder (same dimensions as the QR area to avoid layout shift) -->
|
||||
<div v-if="dingtalkRegister.loading.value && !dingtalkRegister.qrcodeUrl.value" class="dingtalk-register-qrcode dingtalk-register-qrcode--loading">
|
||||
<div class="dingtalk-register-qrcode-spinner"></div>
|
||||
<p class="dingtalk-register-status">{{ t('channels.dingtalkRegister.qrcodeLoading') }}…</p>
|
||||
</div>
|
||||
<div v-else-if="dingtalkRegister.qrcodeUrl.value" class="dingtalk-register-qrcode">
|
||||
<img :src="dingtalkRegister.qrcodeUrl.value" :alt="t('channels.dingtalkRegister.button')" class="dingtalk-register-qrcode-img" />
|
||||
<p class="dingtalk-register-status" :class="dingtalkRegister.status.value">
|
||||
<template v-if="dingtalkRegister.status.value === 'confirmed'">{{ t('channels.dingtalkRegister.confirmed') }}</template>
|
||||
@ -171,7 +176,12 @@
|
||||
? t('channels.feishuRegister.buttonLoading')
|
||||
: t('channels.feishuRegister.button') }}
|
||||
</button>
|
||||
<div v-if="feishuRegister.qrcodeUrl.value" class="feishu-register-qrcode">
|
||||
<!-- Loading placeholder (same dimensions as the QR area to avoid layout shift) -->
|
||||
<div v-if="feishuRegister.loading.value && !feishuRegister.qrcodeUrl.value" class="feishu-register-qrcode feishu-register-qrcode--loading">
|
||||
<div class="feishu-register-qrcode-spinner"></div>
|
||||
<p class="feishu-register-status">{{ t('channels.feishuRegister.qrcodeLoading') }}…</p>
|
||||
</div>
|
||||
<div v-else-if="feishuRegister.qrcodeUrl.value" class="feishu-register-qrcode">
|
||||
<img :src="feishuRegister.qrcodeUrl.value" :alt="t('channels.feishuRegister.button')" class="feishu-register-qrcode-img" />
|
||||
<p class="feishu-register-status" :class="feishuRegister.status.value">
|
||||
<template v-if="feishuRegister.status.value === 'confirmed'">{{ t('channels.feishuRegister.confirmed') }}</template>
|
||||
@ -786,6 +796,9 @@ function save() {
|
||||
.dingtalk-register-btn:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
.dingtalk-register-qrcode { display: flex; flex-direction: column; align-items: center; margin-top: 16px; padding: 16px; background: #fff; border-radius: 8px; border: 1px solid var(--mc-border); }
|
||||
.dingtalk-register-qrcode-img { width: 200px; height: 200px; border-radius: 4px; }
|
||||
.dingtalk-register-qrcode--loading { min-height: 240px; justify-content: center; }
|
||||
.dingtalk-register-qrcode-spinner { width: 40px; height: 40px; border: 3px solid rgba(31,121,255,0.2); border-top-color: #1f79ff; border-radius: 50%; animation: dingtalk-register-spin 0.8s linear infinite; }
|
||||
@keyframes dingtalk-register-spin { to { transform: rotate(360deg); } }
|
||||
.dingtalk-register-status { font-size: 13px; color: var(--mc-text-secondary); margin-top: 10px; transition: color 0.2s; text-align: center; }
|
||||
.dingtalk-register-status.confirmed { color: #10b981; font-weight: 500; }
|
||||
.dingtalk-register-status.expired { color: #f56c6c; }
|
||||
@ -801,6 +814,9 @@ function save() {
|
||||
.feishu-register-btn:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
.feishu-register-qrcode { display: flex; flex-direction: column; align-items: center; margin-top: 16px; padding: 16px; background: #fff; border-radius: 8px; border: 1px solid var(--mc-border); }
|
||||
.feishu-register-qrcode-img { width: 200px; height: 200px; border-radius: 4px; }
|
||||
.feishu-register-qrcode--loading { min-height: 240px; justify-content: center; }
|
||||
.feishu-register-qrcode-spinner { width: 40px; height: 40px; border: 3px solid rgba(99,102,241,0.2); border-top-color: #6366f1; border-radius: 50%; animation: feishu-register-spin 0.8s linear infinite; }
|
||||
@keyframes feishu-register-spin { to { transform: rotate(360deg); } }
|
||||
.feishu-register-status { font-size: 13px; color: var(--mc-text-secondary); margin-top: 10px; transition: color 0.2s; text-align: center; }
|
||||
.feishu-register-status.confirmed { color: #10b981; font-weight: 500; }
|
||||
.feishu-register-status.expired { color: #f56c6c; }
|
||||
|
||||
@ -13,10 +13,11 @@ export interface DingTalkRegisterResult {
|
||||
/**
|
||||
* 钉钉"一键应用注册"前端状态机:
|
||||
* 1. POST /dingtalk/register/begin → sessionId(后端起 worker,开始 5s 轮询 /poll)
|
||||
* 2. 每 2s 轮询 /dingtalk/register/status → 拿到 qrcode_img 渲染二维码 / 拿到 confirmed 时回调
|
||||
* 2. 立即触发一次 status 轮询,之后每 2s 轮询 → 拿到 qrcode_img 渲染 / confirmed 时回调
|
||||
* 3. 终态(confirmed / expired / denied)后停止轮询
|
||||
*
|
||||
* 跟 useFeishuAppRegister 同构,区别仅在 begin 不需要 domain 参数。
|
||||
* `loading` 状态从用户点击开始一直保持 true,直到 QR 真正可见才置 false ——
|
||||
* 期间的 UI 会显示 spinner 占位块,避免出现"按钮已恢复但 QR 还没来"的死页面错觉。
|
||||
*/
|
||||
export function useDingTalkAppRegister(onConfirmed: (r: DingTalkRegisterResult) => void) {
|
||||
const { t } = useI18n()
|
||||
@ -50,30 +51,30 @@ export function useDingTalkAppRegister(onConfirmed: (r: DingTalkRegisterResult)
|
||||
const res: any = await channelApi.dingtalkRegisterBegin()
|
||||
sessionId = res?.data?.session_id || res?.session_id || ''
|
||||
if (!sessionId) {
|
||||
loading.value = false
|
||||
ElMessage.error(t('channels.dingtalkRegister.startFailed'))
|
||||
return
|
||||
}
|
||||
status.value = 'waiting'
|
||||
} catch {
|
||||
loading.value = false
|
||||
ElMessage.error(t('channels.dingtalkRegister.startFailed'))
|
||||
return
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
pollTimer = setInterval(async () => {
|
||||
// Single poll body. Used both for the immediate first call (no 2s wait) and
|
||||
// the subsequent setInterval — DingTalk backend polls every 5s, so the
|
||||
// first qrcode_img usually appears in our 2nd or 3rd poll.
|
||||
const pollOnce = async () => {
|
||||
try {
|
||||
const res: any = await channelApi.dingtalkRegisterStatus(sessionId)
|
||||
const data = res?.data || res || {}
|
||||
const s = (data.status as DingTalkRegisterStatus) || 'waiting'
|
||||
|
||||
// Prefer the backend-rendered base64 PNG. The raw qrcode_url is the
|
||||
// verification URL that needs encoding into a QR image; browsers can't
|
||||
// render plain text as an image. Fall back to URL only as a defensive
|
||||
// last resort.
|
||||
const img = data.qrcode_img || data.qrcode_url
|
||||
if (img && qrcodeUrl.value !== img) {
|
||||
qrcodeUrl.value = img
|
||||
loading.value = false // QR is now visible, button can recover
|
||||
}
|
||||
status.value = s
|
||||
|
||||
@ -81,6 +82,7 @@ export function useDingTalkAppRegister(onConfirmed: (r: DingTalkRegisterResult)
|
||||
if (confirmedFired) return
|
||||
confirmedFired = true
|
||||
stopPolling()
|
||||
loading.value = false
|
||||
const clientId = data.client_id || ''
|
||||
const clientSecret = data.client_secret || ''
|
||||
if (clientId && clientSecret) {
|
||||
@ -92,15 +94,22 @@ export function useDingTalkAppRegister(onConfirmed: (r: DingTalkRegisterResult)
|
||||
|
||||
if (s === 'expired') {
|
||||
stopPolling()
|
||||
loading.value = false
|
||||
ElMessage.warning(t('channels.dingtalkRegister.expired'))
|
||||
} else if (s === 'denied') {
|
||||
stopPolling()
|
||||
loading.value = false
|
||||
ElMessage.warning(t('channels.dingtalkRegister.denied'))
|
||||
}
|
||||
} catch {
|
||||
// Silent — transient network errors should not abort the loop.
|
||||
}
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
// Immediate first poll, then 2s interval. begin() should already have stored
|
||||
// the QR URL, so the very first poll typically already returns it.
|
||||
await pollOnce()
|
||||
pollTimer = setInterval(pollOnce, 2000)
|
||||
}
|
||||
|
||||
onBeforeUnmount(stopPolling)
|
||||
|
||||
@ -12,12 +12,12 @@ export interface FeishuRegisterResult {
|
||||
|
||||
/**
|
||||
* 飞书"一键应用注册"前端状态机:
|
||||
* 1. POST /feishu/register/begin → 拿到 sessionId(后端起 worker,开始向飞书拉 QR)
|
||||
* 2. 每 2s 轮询 /feishu/register/status → 拿到 qrcode_url 渲染二维码 / 拿到 confirmed 时回调
|
||||
* 1. POST /feishu/register/begin → sessionId(后端起 worker,开始向飞书拉 QR)
|
||||
* 2. 立即触发一次 status 轮询,之后每 2s 轮询 → 拿到 qrcode_img 渲染二维码 / confirmed 时回调
|
||||
* 3. 终态(confirmed / expired / denied / error)后停止轮询
|
||||
*
|
||||
* 跟微信的 useWeixinQrcodePoll 同构,但 SDK 不同:飞书是 oapi-sdk RegisterApp,
|
||||
* 微信是 iLink Bot HTTP。
|
||||
* `loading` 状态从用户点击开始一直保持 true,直到 QR 真正可见才置 false ——
|
||||
* 期间的 UI 会显示 spinner 占位块,避免出现"按钮已恢复但 QR 还没来"的死页面错觉。
|
||||
*/
|
||||
export function useFeishuAppRegister(onConfirmed: (r: FeishuRegisterResult) => void) {
|
||||
const { t } = useI18n()
|
||||
@ -51,30 +51,34 @@ export function useFeishuAppRegister(onConfirmed: (r: FeishuRegisterResult) => v
|
||||
const res: any = await channelApi.feishuRegisterBegin(domain)
|
||||
sessionId = res?.data?.session_id || res?.session_id || ''
|
||||
if (!sessionId) {
|
||||
loading.value = false
|
||||
ElMessage.error(t('channels.feishuRegister.startFailed'))
|
||||
return
|
||||
}
|
||||
status.value = 'pending'
|
||||
} catch {
|
||||
loading.value = false
|
||||
ElMessage.error(t('channels.feishuRegister.startFailed'))
|
||||
return
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
pollTimer = setInterval(async () => {
|
||||
// Single poll body. Used both for the immediate first call (no 2s wait) and
|
||||
// the subsequent setInterval — without the immediate call the user stares at
|
||||
// a button-disabled-but-no-QR window for up to two seconds.
|
||||
const pollOnce = async () => {
|
||||
try {
|
||||
const res: any = await channelApi.feishuRegisterStatus(sessionId)
|
||||
const data = res?.data || res || {}
|
||||
const s = (data.status as FeishuRegisterStatus) || 'pending'
|
||||
|
||||
// Prefer the backend-rendered base64 PNG (data: URI). The raw qrcode_url
|
||||
// is the verification URL that needs to be encoded into a QR image —
|
||||
// browsers can't render plain text as an image. Fall back to the URL
|
||||
// only as a defensive last resort.
|
||||
// Prefer the backend-rendered base64 PNG. The raw qrcode_url is the
|
||||
// verification URL that needs encoding into a QR image; browsers can't
|
||||
// render plain text as an image. Fall back to URL only as a defensive
|
||||
// last resort.
|
||||
const img = data.qrcode_img || data.qrcode_url
|
||||
if (img && qrcodeUrl.value !== img) {
|
||||
qrcodeUrl.value = img
|
||||
loading.value = false // QR is now visible, button can recover
|
||||
}
|
||||
status.value = s
|
||||
|
||||
@ -82,6 +86,7 @@ export function useFeishuAppRegister(onConfirmed: (r: FeishuRegisterResult) => v
|
||||
if (confirmedFired) return
|
||||
confirmedFired = true
|
||||
stopPolling()
|
||||
loading.value = false
|
||||
const appId = data.client_id || ''
|
||||
const appSecret = data.client_secret || ''
|
||||
if (appId && appSecret) {
|
||||
@ -93,18 +98,27 @@ export function useFeishuAppRegister(onConfirmed: (r: FeishuRegisterResult) => v
|
||||
|
||||
if (s === 'expired') {
|
||||
stopPolling()
|
||||
loading.value = false
|
||||
ElMessage.warning(t('channels.feishuRegister.expired'))
|
||||
} else if (s === 'denied') {
|
||||
stopPolling()
|
||||
loading.value = false
|
||||
ElMessage.warning(t('channels.feishuRegister.denied'))
|
||||
} else if (s === 'error') {
|
||||
stopPolling()
|
||||
loading.value = false
|
||||
ElMessage.error(t('channels.feishuRegister.error'))
|
||||
}
|
||||
} catch {
|
||||
// Silent — transient network errors should not abort the loop.
|
||||
}
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
// Immediate first poll, then 2s interval. The first poll usually returns
|
||||
// pending (worker hasn't received onQRCode yet) — the second one ~2s
|
||||
// later typically has the QR ready.
|
||||
await pollOnce()
|
||||
pollTimer = setInterval(pollOnce, 2000)
|
||||
}
|
||||
|
||||
onBeforeUnmount(stopPolling)
|
||||
|
||||
@ -1648,6 +1648,7 @@ export default {
|
||||
hint: 'Click the button to get a QR code. Scan it with DingTalk and approve authorization — a bot app will be created in your tenant and the Client ID / Client Secret will be auto-filled below.',
|
||||
button: 'Scan to Create Bot',
|
||||
buttonLoading: 'Preparing QR code…',
|
||||
qrcodeLoading: 'Generating QR code',
|
||||
scanHint: 'Scan the QR code above with the DingTalk app and confirm authorization',
|
||||
confirmed: 'Bot created. Client ID and Secret have been auto-filled.',
|
||||
expired: 'QR code expired, please try again',
|
||||
@ -1659,6 +1660,7 @@ export default {
|
||||
hint: 'Click the button to get a QR code. Scan it with Feishu and confirm authorization — an in-house app will be created in your tenant and the App ID / App Secret will be auto-filled below.',
|
||||
button: 'Scan to Create App',
|
||||
buttonLoading: 'Preparing QR code…',
|
||||
qrcodeLoading: 'Generating QR code',
|
||||
scanHint: 'Scan the QR code above with the Feishu app and confirm authorization',
|
||||
confirmed: 'App created. App ID and Secret have been auto-filled.',
|
||||
expired: 'QR code expired, please try again',
|
||||
|
||||
@ -1658,6 +1658,7 @@ export default {
|
||||
hint: '点击按钮获取二维码,使用钉钉扫一扫并授权后,会自动在你的企业里创建机器人应用,Client ID / Client Secret 自动填入下方。',
|
||||
button: '扫码自动创建机器人',
|
||||
buttonLoading: '正在准备二维码…',
|
||||
qrcodeLoading: '正在生成二维码',
|
||||
scanHint: '请使用钉钉 App 扫描上方二维码并确认授权',
|
||||
confirmed: '机器人创建成功,Client ID 和 Secret 已自动填入',
|
||||
expired: '二维码已过期,请重新生成',
|
||||
@ -1669,6 +1670,7 @@ export default {
|
||||
hint: '点击按钮获取二维码,使用飞书扫一扫并确认授权后,会自动在你的企业里创建自建应用,App ID / App Secret 自动填入下方。',
|
||||
button: '扫码自动创建应用',
|
||||
buttonLoading: '正在准备二维码…',
|
||||
qrcodeLoading: '正在生成二维码',
|
||||
scanHint: '请使用飞书 App 扫描上方二维码并确认授权',
|
||||
confirmed: '应用创建成功,App ID 和 Secret 已自动填入',
|
||||
expired: '二维码已过期,请重新生成',
|
||||
|
||||
Loading…
Reference in New Issue
Block a user