Files
knowledge-governance/web/src/DingtalkSessionControl.tsx
T
姚章浩 e34306b2db
build / build (push) Failing after 18s
fix(ui): 已释放窗口不再渲染 held 文案,版本页不再把 500 说成未接线
发布管理: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 档案)。
2026-11-01 19:34:31 +08:00

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