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:
matevip 2026-04-28 11:14:14 +08:00
parent 5bef83a156
commit b4aef56c89
5 changed files with 67 additions and 24 deletions

View File

@ -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; }

View File

@ -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)

View File

@ -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)

View File

@ -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',

View File

@ -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: '二维码已过期,请重新生成',