diff --git a/mateclaw-ui/src/components/channels/ChannelEditModal.vue b/mateclaw-ui/src/components/channels/ChannelEditModal.vue index 3a3f1959..7935a92a 100644 --- a/mateclaw-ui/src/components/channels/ChannelEditModal.vue +++ b/mateclaw-ui/src/components/channels/ChannelEditModal.vue @@ -138,7 +138,12 @@ ? t('channels.dingtalkRegister.buttonLoading') : t('channels.dingtalkRegister.button') }} -
{{ t('channels.dingtalkRegister.qrcodeLoading') }}…
+{{ t('channels.dingtalkRegister.confirmed') }} @@ -171,7 +176,12 @@ ? t('channels.feishuRegister.buttonLoading') : t('channels.feishuRegister.button') }} -
{{ t('channels.feishuRegister.qrcodeLoading') }}…
+{{ t('channels.feishuRegister.confirmed') }} @@ -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; } diff --git a/mateclaw-ui/src/composables/channels/useDingTalkAppRegister.ts b/mateclaw-ui/src/composables/channels/useDingTalkAppRegister.ts index d5be922b..7b070b83 100644 --- a/mateclaw-ui/src/composables/channels/useDingTalkAppRegister.ts +++ b/mateclaw-ui/src/composables/channels/useDingTalkAppRegister.ts @@ -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) diff --git a/mateclaw-ui/src/composables/channels/useFeishuAppRegister.ts b/mateclaw-ui/src/composables/channels/useFeishuAppRegister.ts index e9d6dafe..63b76fa9 100644 --- a/mateclaw-ui/src/composables/channels/useFeishuAppRegister.ts +++ b/mateclaw-ui/src/composables/channels/useFeishuAppRegister.ts @@ -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) diff --git a/mateclaw-ui/src/i18n/locales/en-US.ts b/mateclaw-ui/src/i18n/locales/en-US.ts index c67aae6b..2bf3ac8f 100644 --- a/mateclaw-ui/src/i18n/locales/en-US.ts +++ b/mateclaw-ui/src/i18n/locales/en-US.ts @@ -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', diff --git a/mateclaw-ui/src/i18n/locales/zh-CN.ts b/mateclaw-ui/src/i18n/locales/zh-CN.ts index 76181c23..f34df07b 100644 --- a/mateclaw-ui/src/i18n/locales/zh-CN.ts +++ b/mateclaw-ui/src/i18n/locales/zh-CN.ts @@ -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: '二维码已过期,请重新生成',