e34306b2db
build / build (push) Failing after 18s
发布管理:maintenanceSummary 只看 job.state === 'failed' 就给 held 处置文案; 窗口已 released 的旧作业(libsync-internal-1793399248431728863)因此自相矛盾。 改为仅 held 时给 recoveryError,已释放时给 releasedFailure(如实记录失败,不提供强制成功)。 版本记录:LoadVersionHistorySyncState 把可空的 finished_at 扫进 time.Time; 部署重启触发的同步在"进行中"时该列为 NULL,扫描失败使整页 500 (nginx access log:GET /api/v1/version-history?limit=30 → 500,74 字节错误体)。 改为指针接值再判空。前端同时区分"未配置/加载失败"并显示服务端 code。 RED/GREEN:新增 Go 集成用例(NULL finished_at)、两条前端用例; make verify 16/16 通过(local 档案)。
184 lines
6.0 KiB
TypeScript
184 lines
6.0 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
|
|
import { cancelDingtalkSession, fetchDingtalkSession, startDingtalkSession } from './dingtalkSession'
|
|
import type { DingtalkSessionView } from './dingtalkSession'
|
|
|
|
// Admin-only card control: "扫码刷新会话" opens a login session against the
|
|
// dingtalk-login sidecar, shows its QR, and reports the capture result. The
|
|
// token never reaches this component — only state and the QR image.
|
|
|
|
const finished = new Set(['success', 'expired', 'failed', 'closed'])
|
|
|
|
interface DingtalkSessionControlProps {
|
|
triggerLabel?: string
|
|
autoStart?: boolean
|
|
hideTrigger?: boolean
|
|
space?: string
|
|
onSuccess?: () => void
|
|
onClose?: () => void
|
|
}
|
|
|
|
export function DingtalkSessionControl({
|
|
autoStart = false,
|
|
hideTrigger = false,
|
|
space,
|
|
onSuccess,
|
|
onClose,
|
|
triggerLabel = '扫码刷新会话',
|
|
}: DingtalkSessionControlProps = {}) {
|
|
const [session, setSession] = useState<DingtalkSessionView | null>(null)
|
|
const [failure, setFailure] = useState<string | null>(null)
|
|
const [starting, setStarting] = useState(false)
|
|
const [open, setOpen] = useState(false)
|
|
const [lastSuccess, setLastSuccess] = useState(false)
|
|
const idRef = useRef<string | null>(null)
|
|
const autoStartedRef = useRef(false)
|
|
const successHandledRef = useRef(false)
|
|
|
|
useEffect(
|
|
() => () => {
|
|
if (idRef.current) void cancelDingtalkSession(idRef.current)
|
|
},
|
|
[]
|
|
)
|
|
|
|
useEffect(() => {
|
|
if (!session || finished.has(session.state)) return
|
|
const timer = setInterval(() => {
|
|
fetchDingtalkSession(session.id)
|
|
.then(setSession)
|
|
.catch(() => undefined)
|
|
}, 1500)
|
|
return () => clearInterval(timer)
|
|
}, [session?.id, session?.state])
|
|
|
|
const close = useCallback(
|
|
(cancel = true) => {
|
|
if (cancel && idRef.current) void cancelDingtalkSession(idRef.current)
|
|
idRef.current = null
|
|
setSession(null)
|
|
setStarting(false)
|
|
setOpen(false)
|
|
onClose?.()
|
|
},
|
|
[onClose]
|
|
)
|
|
|
|
const start = useCallback(async () => {
|
|
// Show the dialog immediately. Launching Chromium and rendering the QR can
|
|
// take several seconds and must never look like an ignored click.
|
|
setOpen(true)
|
|
setStarting(true)
|
|
setFailure(null)
|
|
setSession(null)
|
|
setLastSuccess(false)
|
|
successHandledRef.current = false
|
|
try {
|
|
const fresh = await startDingtalkSession(space)
|
|
idRef.current = fresh.id
|
|
setSession(fresh)
|
|
} catch (cause) {
|
|
setFailure(cause instanceof Error ? cause.message : '发起扫码会话失败')
|
|
} finally {
|
|
setStarting(false)
|
|
}
|
|
}, [space])
|
|
|
|
useEffect(() => {
|
|
if (!autoStart || autoStartedRef.current) return
|
|
autoStartedRef.current = true
|
|
void start()
|
|
}, [autoStart, start])
|
|
|
|
useEffect(() => {
|
|
if (session?.state !== 'success' || successHandledRef.current) return
|
|
successHandledRef.current = true
|
|
const timer = window.setTimeout(() => {
|
|
setLastSuccess(true)
|
|
onSuccess?.()
|
|
close(false)
|
|
}, 700)
|
|
return () => window.clearTimeout(timer)
|
|
}, [session?.state, onSuccess, close])
|
|
|
|
const stateLabel: Record<string, string> = {
|
|
starting: '正在生成二维码,请稍候…',
|
|
qr: '请用钉钉 App 扫码登录',
|
|
scanned: '已扫码,正在验证并继续…',
|
|
reusing: '检测到有效浏览器会话,正在验证…',
|
|
success: '✅ 钉钉扫码会话已刷新,扫描任务可继续使用',
|
|
expired: '二维码已过期,请重新发起',
|
|
failed: '会话验证失败,请重试或联系管理员',
|
|
closed: '会话已关闭',
|
|
}
|
|
|
|
const generating = open && !failure && (starting || !session || session.state === 'starting')
|
|
|
|
return (
|
|
<div className="dingtalk-session-control">
|
|
{!hideTrigger ? (
|
|
<button
|
|
className="secondary"
|
|
disabled={starting || (session ? !finished.has(session.state) : false)}
|
|
onClick={() => void start()}
|
|
>
|
|
{session && !finished.has(session.state) ? '扫码进行中…' : triggerLabel}
|
|
</button>
|
|
) : null}
|
|
{lastSuccess && !hideTrigger ? (
|
|
<p className="success-message" role="status">
|
|
✅ 钉钉会话已刷新
|
|
</p>
|
|
) : null}
|
|
{failure && !open ? (
|
|
<p className="error-message" role="alert">
|
|
{failure}
|
|
</p>
|
|
) : null}
|
|
{open ? (
|
|
<div className="modal-backdrop" role="dialog" aria-modal="true" aria-label="钉钉扫码登录">
|
|
<div className="modal-card dingtalk-qr-card">
|
|
<h3>刷新钉钉扫码会话</h3>
|
|
<p className="dingtalk-qr-hint" role="status" aria-live="polite">
|
|
{generating
|
|
? '正在生成二维码,请稍候…'
|
|
: session
|
|
? (stateLabel[session.state] ?? session.state)
|
|
: '二维码生成失败,请重试'}
|
|
</p>
|
|
{generating ? (
|
|
<div className="dingtalk-qr-loading" aria-hidden="true">
|
|
<span />
|
|
</div>
|
|
) : null}
|
|
{session?.qr_png && session.state === 'qr' ? (
|
|
<img className="dingtalk-qr-image" src={`data:image/png;base64,${session.qr_png}`} alt="钉钉登录二维码" />
|
|
) : null}
|
|
{session?.error ? (
|
|
<p className="error-message" role="alert">
|
|
{session.error}
|
|
</p>
|
|
) : null}
|
|
{failure ? (
|
|
<p className="error-message" role="alert">
|
|
{failure}
|
|
</p>
|
|
) : null}
|
|
<div className="modal-actions">
|
|
{session && finished.has(session.state) ? (
|
|
<button className="secondary" onClick={() => close(false)}>
|
|
关闭
|
|
</button>
|
|
) : (
|
|
<button className="danger" onClick={() => close(true)}>
|
|
取消扫码
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
)
|
|
}
|