fix: 统一考核调研确认弹窗

This commit is contained in:
chenpei 2026-07-27 18:32:34 +08:00
parent b2c3b20390
commit b07088affe
3 changed files with 567 additions and 81 deletions

View File

@ -1,6 +1,6 @@
/** @jest-environment jsdom */
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { PerformanceSurveyAdminConfig } from './PerformanceSurveyAdminConfig'
const templateDefinition = {
@ -133,6 +133,12 @@ function createRequest(configuration = config) {
}
}
if (path === '/admin/position-groups' && options?.method === 'POST') return { id: 'group-3' }
if (path === '/admin/position-groups/group-1' && options?.method === 'DELETE') {
return { id: 'group-1', action: 'deleted' }
}
if (path === '/admin/templates/template-1' && options?.method === 'DELETE') {
return { id: 'template-1', action: 'deleted' }
}
if (path.endsWith('/clone')) return { id: 'draft-version-2' }
return {}
})
@ -140,7 +146,6 @@ function createRequest(configuration = config) {
describe('PerformanceSurveyAdminConfig', () => {
beforeEach(() => {
jest.spyOn(window, 'confirm').mockReturnValue(true)
Object.defineProperty(window, 'matchMedia', {
configurable: true,
value: jest.fn().mockReturnValue({ matches: false }),
@ -180,7 +185,7 @@ describe('PerformanceSurveyAdminConfig', () => {
await waitFor(() => expect(screen.getByRole('heading', { name: '2026-08 配置概览' })).toBeInTheDocument())
expect(screen.getByText(/1\/2 个在用岗位已有生效方案,.*1 个沿用较早版本/)).toBeInTheDocument()
fireEvent.change(screen.getByLabelText('查看月份'), { target: { value: '2026-09' } })
expect(onMonthChange).toHaveBeenCalledWith('2026-09')
await waitFor(() => expect(onMonthChange).toHaveBeenCalledWith('2026-09'))
})
test('岗位编辑产生 dirty 状态并保护岗位切换', async () => {
@ -192,15 +197,40 @@ describe('PerformanceSurveyAdminConfig', () => {
fireEvent.change(screen.getByLabelText('岗位名称'), { target: { value: '品牌视觉设计师' } })
expect(onDirtyChange).toHaveBeenLastCalledWith(true)
;(window.confirm as jest.Mock).mockReturnValueOnce(false)
fireEvent.click(screen.getByRole('button', { name: /AI 技术设计师/ }))
expect(await screen.findByRole('alertdialog', { name: '放弃未保存的修改?' })).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '取消' }))
expect(screen.getByLabelText('岗位名称')).toHaveValue('品牌视觉设计师')
;(window.confirm as jest.Mock).mockReturnValueOnce(true)
fireEvent.click(screen.getByRole('button', { name: /AI 技术设计师/ }))
fireEvent.click(await screen.findByRole('button', { name: '放弃修改' }))
await waitFor(() => expect(screen.getByLabelText('岗位名称')).toHaveValue('AI 技术设计师'))
})
test('删除岗位使用统一弹窗收集必填原因并提交', async () => {
const request = createRequest()
render(<PerformanceSurveyAdminConfig mode="positions" request={request} />)
await waitFor(() => expect(screen.getByLabelText('岗位名称')).toHaveValue('品牌设计师'))
fireEvent.click(screen.getByRole('button', { name: '删除岗位' }))
const dialog = await screen.findByRole('alertdialog', { name: '删除岗位“品牌设计师”?' })
const dialogQueries = within(dialog)
expect(dialog).toHaveTextContent('当前仍有 6 人属于该岗位')
expect(dialog).toHaveTextContent('历史调研记录会保留')
expect(dialogQueries.getByRole('button', { name: '删除岗位' })).toBeDisabled()
fireEvent.change(dialogQueries.getByLabelText(/删除原因/), { target: { value: '岗位合并' } })
fireEvent.click(dialogQueries.getByRole('button', { name: '删除岗位' }))
await waitFor(() => expect(request).toHaveBeenCalledWith(
'/admin/position-groups/group-1',
expect.objectContaining({
method: 'DELETE',
body: JSON.stringify({ mode: 'auto', reason: '岗位合并' }),
}),
))
})
test('岗位页以搜索方式维护草稿考核项,并只展示已选择内容', async () => {
const configuration = {
...config,
@ -300,6 +330,72 @@ describe('PerformanceSurveyAdminConfig', () => {
expect(screen.queryByText('使用场景')).not.toBeInTheDocument()
})
test('删除模板使用统一弹窗并保留接口错误以便重试', async () => {
const baseRequest = createRequest()
let deleteAttempts = 0
const request = jest.fn(async (path: string, options?: RequestInit) => {
if (path === '/admin/templates/template-1' && options?.method === 'DELETE') {
deleteAttempts += 1
if (deleteAttempts === 1) throw new Error('模板仍被岗位使用,请先移除岗位考核项')
}
return baseRequest(path, options)
})
render(<PerformanceSurveyAdminConfig mode="templates" request={request} />)
await waitFor(() => expect(screen.getByText(/v1 · 已发布/)).toBeInTheDocument())
fireEvent.click(screen.getByRole('button', { name: '删除模板' }))
const dialog = await screen.findByRole('alertdialog', { name: '删除模板“视觉案例评分”?' })
const dialogQueries = within(dialog)
expect(dialog).toHaveTextContent('历史调研中的模板和评分数据会继续保留')
fireEvent.change(dialogQueries.getByLabelText(/删除原因/), { target: { value: '模板重复' } })
fireEvent.click(dialogQueries.getByRole('button', { name: '删除模板' }))
expect(await dialogQueries.findByRole('alert')).toHaveTextContent('模板仍被岗位使用')
expect(dialogQueries.getByLabelText(/删除原因/)).toHaveValue('模板重复')
fireEvent.click(dialogQueries.getByRole('button', { name: '删除模板' }))
await waitFor(() => expect(request).toHaveBeenCalledWith(
'/admin/templates/template-1',
expect.objectContaining({
method: 'DELETE',
body: JSON.stringify({ mode: 'auto', reason: '模板重复' }),
}),
))
expect(deleteAttempts).toBe(2)
})
test('发布模板通过统一弹窗确认模板和生效月份', async () => {
const configuration = {
...config,
templates: config.templates.map((template: any) => template.id === 'template-1'
? {
...template,
versions: template.versions.map((version: any) => ({
...version,
status: 'draft',
effectiveMonth: null,
})),
}
: template),
}
const request = createRequest(configuration)
render(<PerformanceSurveyAdminConfig mode="templates" request={request} />)
await waitFor(() => expect(screen.getByText(/v1 · 草稿/)).toBeInTheDocument())
fireEvent.click(screen.getByRole('button', { name: /发布至 2026-07/ }))
const dialog = await screen.findByRole('dialog', { name: '发布模板“视觉案例评分”?' })
expect(dialog).toHaveTextContent('模板将从 2026-07 起供岗位考核方案使用')
fireEvent.click(within(dialog).getByRole('button', { name: '发布至 2026-07' }))
await waitFor(() => expect(request).toHaveBeenCalledWith(
'/admin/templates/version-1/publish',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ effectiveMonth: '2026-07' }),
}),
))
})
test('模板编辑按基本信息、评分项、评审与计分分步展示', async () => {
const request = createRequest()
render(<PerformanceSurveyAdminConfig mode="templates" request={request} />)

View File

@ -1,12 +1,18 @@
'use client'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
useConfirmActionDialog,
type ConfirmActionOptions,
type ConfirmActionResult,
} from '@/components/ConfirmActionDialog'
import {
PerformanceSurveyAdminPeople,
type PerformanceSurveyAdminRequest,
} from './PerformanceSurveyAdminPeople'
type RequestFn = PerformanceSurveyAdminRequest
type ConfirmActionFn = (options: ConfirmActionOptions) => Promise<ConfirmActionResult>
export type PerformanceSurveyAdminMode = 'overview' | 'positions' | 'people' | 'templates' | 'plans'
@ -222,6 +228,7 @@ export function PerformanceSurveyAdminConfig({
const [notice, setNotice] = useState<string | null>(null)
const [dirty, setDirty] = useState(false)
const requestSequenceRef = useRef(0)
const { confirmAction, confirmActionDialog } = useConfirmActionDialog()
const setDirtyState = useCallback((next: boolean) => {
setDirty(next)
@ -264,13 +271,20 @@ export function PerformanceSurveyAdminConfig({
return () => window.removeEventListener('beforeunload', handleBeforeUnload)
}, [dirty])
const confirmDiscard = useCallback((message = '当前有未保存修改,确定放弃吗?') => (
!dirty || window.confirm(message)
), [dirty])
const confirmDiscard = useCallback(async (message = '当前有未保存修改,确定放弃吗?') => {
if (!dirty) return true
const result = await confirmAction({
title: '放弃未保存的修改?',
description: message,
confirmLabel: '放弃修改',
tone: 'danger',
})
return result.confirmed
}, [confirmAction, dirty])
const changeMonth = (nextMonth: string) => {
const changeMonth = async (nextMonth: string) => {
if (!nextMonth || nextMonth === month) return
if (!confirmDiscard('切换月份会放弃当前未保存修改,确定继续吗?')) return
if (!await confirmDiscard('切换月份会放弃当前未保存修改,确定继续吗?')) return
setDirtyState(false)
setNotice(null)
if (onMonthChange) onMonthChange(nextMonth)
@ -314,7 +328,7 @@ export function PerformanceSurveyAdminConfig({
<input
type="month"
value={month}
onChange={(event) => changeMonth(event.target.value)}
onChange={(event) => void changeMonth(event.target.value)}
className="h-11 rounded border border-[#c2c6d8] px-3 text-[13px] font-semibold text-[#1a1c1c] outline-none focus:border-[#0050cb] focus:ring-2 focus:ring-[#dbe6ff]"
/>
</label>
@ -355,6 +369,8 @@ export function PerformanceSurveyAdminConfig({
onChanged={changed}
onDirtyChange={setDirtyState}
confirmDiscard={confirmDiscard}
confirmAction={confirmAction}
dirty={dirty}
/>
)}
{mode === 'people' && (
@ -378,6 +394,7 @@ export function PerformanceSurveyAdminConfig({
onChanged={changed}
onDirtyChange={setDirtyState}
confirmDiscard={confirmDiscard}
confirmAction={confirmAction}
dirty={dirty}
/>
)}
@ -392,6 +409,7 @@ export function PerformanceSurveyAdminConfig({
)}
</div>
)}
{confirmActionDialog}
</section>
)
}
@ -615,6 +633,8 @@ function PositionConfigWorkspace({
onChanged,
onDirtyChange,
confirmDiscard,
confirmAction,
dirty,
}: {
data: AdminConfig
month: string
@ -622,7 +642,9 @@ function PositionConfigWorkspace({
onError: (message: string | null) => void
onChanged: (message: string) => Promise<AdminConfig | null>
onDirtyChange: (dirty: boolean) => void
confirmDiscard: (message?: string) => boolean
confirmDiscard: (message?: string) => Promise<boolean>
confirmAction: ConfirmActionFn
dirty: boolean
}) {
const [selectedId, setSelectedId] = useState(data.groups[0]?.id || '')
const selected = data.groups.find((group) => group.id === selectedId) || data.groups[0]
@ -718,28 +740,43 @@ function PositionConfigWorkspace({
}
const removeGroup = async () => {
if (!selected || !confirmDiscard()) return
if (!selected) return
const memberWarning = selected.memberCount > 0
? `当前仍有 ${selected.memberCount} 人属于该岗位,删除后这些人员会变为待分配。`
: '该岗位当前没有人员。'
if (!window.confirm(`确认删除岗位“${selected.name}”吗?${memberWarning}历史调研记录不会删除。`)) return
const reason = window.prompt('请输入删除原因(用于操作记录)')?.trim()
if (!reason) return
setSaving(true)
onError(null)
try {
const result = await adminConfigApi.removePositionGroup(request, selected.id, reason)
const next = await onChanged(
result.action === 'deleted'
? `岗位“${selected.name}”已删除`
: `岗位“${selected.name}”已从管理列表删除,历史记录已保留`,
)
setSelectedId(next?.groups[0]?.id || '')
} catch (actionError) {
onError(actionError instanceof Error ? actionError.message : '岗位删除失败')
} finally {
setSaving(false)
}
await confirmAction({
title: `删除岗位“${selected.name}”?`,
description: (
<div className="grid gap-1">
<p>{memberWarning}</p>
<p>历史调研记录会保留,但该岗位将不再出现在当前管理列表中。</p>
{dirty && <p className="font-semibold text-[#93000a]">当前未保存的岗位修改也会同时放弃。</p>}
</div>
),
confirmLabel: '删除岗位',
loadingLabel: '删除中...',
tone: 'danger',
reason: {
label: '删除原因',
placeholder: '例如:岗位合并、岗位不再参与考核',
required: true,
},
action: async (reason) => {
setSaving(true)
onError(null)
try {
const removeResult = await adminConfigApi.removePositionGroup(request, selected.id, reason)
const next = await onChanged(
removeResult.action === 'deleted'
? `岗位“${selected.name}”已删除`
: `岗位“${selected.name}”已从管理列表删除,历史记录已保留`,
)
setSelectedId(next?.groups[0]?.id || '')
} finally {
setSaving(false)
}
},
})
}
const addTemplateVersion = (versionId: string) => {
@ -765,9 +802,9 @@ function PositionConfigWorkspace({
markDirty()
}
const selectGroup = (groupId: string) => {
const selectGroup = async (groupId: string) => {
if (groupId === selected?.id) return
if (!confirmDiscard('切换岗位会放弃当前未保存修改,确定继续吗?')) return
if (!await confirmDiscard('切换岗位会放弃当前未保存修改,确定继续吗?')) return
onDirtyChange(false)
setSelectedId(groupId)
if (typeof window.matchMedia === 'function' && window.matchMedia('(max-width: 767px)').matches) {
@ -806,7 +843,7 @@ function PositionConfigWorkspace({
<button
key={group.id}
type="button"
onClick={() => selectGroup(group.id)}
onClick={() => void selectGroup(group.id)}
className={`grid w-full grid-cols-[minmax(0,1fr)_auto] items-center gap-3 border-b border-[#eeeeee] px-4 py-3 text-left ${selected.id === group.id ? 'bg-[#f1f4ff]' : 'hover:bg-[#fafafa]'}`}
>
<span className="min-w-0">
@ -917,6 +954,7 @@ function TemplateConfigWorkspace({
onChanged,
onDirtyChange,
confirmDiscard,
confirmAction,
dirty,
}: {
data: AdminConfig
@ -925,7 +963,8 @@ function TemplateConfigWorkspace({
onError: (message: string | null) => void
onChanged: (message: string) => Promise<AdminConfig | null>
onDirtyChange: (dirty: boolean) => void
confirmDiscard: (message?: string) => boolean
confirmDiscard: (message?: string) => Promise<boolean>
confirmAction: ConfirmActionFn
dirty: boolean
}) {
const firstTemplate = data.templates[0]
@ -992,7 +1031,7 @@ function TemplateConfigWorkspace({
}
const cloneVersion = async () => {
if (!versionId || !confirmDiscard()) return
if (!versionId || !await confirmDiscard()) return
setSaving(true)
onError(null)
try {
@ -1014,48 +1053,73 @@ function TemplateConfigWorkspace({
onError('请先保存模板草稿,再执行发布')
return
}
if (!window.confirm(`确认将“${definition.name}”发布至 ${month} 吗?已发布版本不可直接修改。`)) return
setSaving(true)
onError(null)
try {
await request(`/admin/templates/${versionId}/publish`, {
method: 'POST',
body: JSON.stringify({ effectiveMonth: month }),
})
await onChanged(`${definition.name} 已从 ${month} 起发布`)
} catch (actionError) {
onError(actionError instanceof Error ? actionError.message : '模板发布失败')
} finally {
setSaving(false)
}
await confirmAction({
title: `发布模板“${definition.name}”?`,
description: (
<div className="grid gap-1">
<p>模板将从 {month} 起供岗位考核方案使用。</p>
<p>发布后的版本不可直接修改;后续调整需要创建新的草稿版本。</p>
</div>
),
confirmLabel: `发布至 ${month}`,
loadingLabel: '发布中...',
action: async () => {
setSaving(true)
onError(null)
try {
await request(`/admin/templates/${versionId}/publish`, {
method: 'POST',
body: JSON.stringify({ effectiveMonth: month }),
})
await onChanged(`${definition.name} 已从 ${month} 起发布`)
} finally {
setSaving(false)
}
},
})
}
const removeTemplate = async () => {
if (!selectedTemplate || !confirmDiscard()) return
if (!selectedTemplate) return
const displayName = version?.definition.name || selectedTemplate.versions[0]?.definition.name || selectedTemplate.name
if (!window.confirm(`确认删除模板“${displayName}”吗?已被历史调研引用的数据会保留;正在被岗位使用的模板需要先从岗位考核项中移除。`)) return
const reason = window.prompt('请输入删除原因(用于操作记录)')?.trim()
if (!reason) return
setSaving(true)
onError(null)
try {
const result = await adminConfigApi.removeTemplate(request, selectedTemplate.id, reason)
const next = await onChanged(
result.action === 'deleted'
? `模板“${displayName}”已删除`
: `模板“${displayName}”已从模板列表删除,历史记录已保留`,
)
setSelectedTemplateId(next?.templates[0]?.id || 'new')
} catch (actionError) {
onError(actionError instanceof Error ? actionError.message : '模板删除失败')
} finally {
setSaving(false)
}
await confirmAction({
title: `删除模板“${displayName}”?`,
description: (
<div className="grid gap-1">
<p>历史调研中的模板和评分数据会继续保留。</p>
<p>如果岗位仍在使用该模板,需要先从岗位考核项中移除后才能删除。</p>
{dirty && <p className="font-semibold text-[#93000a]">当前未保存的模板修改也会同时放弃。</p>}
</div>
),
confirmLabel: '删除模板',
loadingLabel: '删除中...',
tone: 'danger',
reason: {
label: '删除原因',
placeholder: '例如:模板重复、考核规则已停用',
required: true,
},
action: async (reason) => {
setSaving(true)
onError(null)
try {
const removeResult = await adminConfigApi.removeTemplate(request, selectedTemplate.id, reason)
const next = await onChanged(
removeResult.action === 'deleted'
? `模板“${displayName}”已删除`
: `模板“${displayName}”已从模板列表删除,历史记录已保留`,
)
setSelectedTemplateId(next?.templates[0]?.id || 'new')
} finally {
setSaving(false)
}
},
})
}
const selectTemplate = (templateId: string) => {
const selectTemplate = async (templateId: string) => {
if (templateId === selectedTemplateId) return
if (!confirmDiscard('切换模板会放弃当前未保存修改,确定继续吗?')) return
if (!await confirmDiscard('切换模板会放弃当前未保存修改,确定继续吗?')) return
onDirtyChange(false)
setSelectedTemplateId(templateId)
if (typeof window.matchMedia === 'function' && window.matchMedia('(max-width: 767px)').matches) {
@ -1063,13 +1127,22 @@ function TemplateConfigWorkspace({
}
}
const selectVersion = async (nextVersion: TemplateVersion) => {
if (nextVersion.id === versionId) return
if (!await confirmDiscard('切换版本会放弃当前未保存修改,确定继续吗?')) return
onDirtyChange(false)
setVersionId(nextVersion.id)
setDefinition(clone(nextVersion.definition))
setEditorStep('basics')
}
return (
<div className="grid min-h-[680px] md:grid-cols-[260px_minmax(0,1fr)]">
<aside className="border-b border-[#e2e2e2] md:border-b-0 md:border-r">
<div className="border-b border-[#c2c6d8] p-3">
<button
type="button"
onClick={() => selectTemplate('new')}
onClick={() => void selectTemplate('new')}
className="min-h-11 w-full rounded bg-[#1a1c1c] px-4 text-center text-[13px] font-bold text-white hover:bg-black"
>
新建模板
@ -1079,7 +1152,7 @@ function TemplateConfigWorkspace({
<button
key={template.id}
type="button"
onClick={() => selectTemplate(template.id)}
onClick={() => void selectTemplate(template.id)}
className={`w-full border-b border-[#eeeeee] px-4 py-3 text-left ${selectedTemplateId === template.id ? 'bg-[#f1f4ff]' : 'hover:bg-[#fafafa]'}`}
>
<span className="block truncate text-[13px] font-bold text-[#1a1c1c]">{template.versions[0]?.definition.name || template.name}</span>
@ -1096,14 +1169,7 @@ function TemplateConfigWorkspace({
<button
key={item.id}
type="button"
onClick={() => {
if (item.id === versionId) return
if (!confirmDiscard('切换版本会放弃当前未保存修改,确定继续吗?')) return
onDirtyChange(false)
setVersionId(item.id)
setDefinition(clone(item.definition))
setEditorStep('basics')
}}
onClick={() => void selectVersion(item)}
className={`min-h-10 rounded border px-3 text-left text-[12px] font-bold ${versionId === item.id ? 'border-[#0050cb] bg-[#f1f4ff] text-[#0050cb]' : 'border-[#e2e2e2] text-[#5f5e5e]'}`}
>
<span className="block">v{item.version} · {item.status === 'draft' ? '草稿' : item.status === 'published' ? '已发布' : '已归档'}</span>

View File

@ -0,0 +1,324 @@
'use client'
import {
type KeyboardEvent,
type ReactNode,
useCallback,
useEffect,
useId,
useRef,
useState,
} from 'react'
import { createPortal } from 'react-dom'
export type ConfirmActionOptions = {
title: string
description: ReactNode
confirmLabel?: string
loadingLabel?: string
cancelLabel?: string
tone?: 'primary' | 'danger'
action?: (reason: string) => Promise<void> | void
reason?: {
label?: string
placeholder?: string
helperText?: string
required?: boolean
}
}
export type ConfirmActionResult = {
confirmed: boolean
reason: string
}
type ConfirmActionDialogProps = ConfirmActionOptions & {
open: boolean
loading: boolean
error: string | null
reasonValue: string
onReasonChange: (value: string) => void
onCancel: () => void
onConfirm: () => void
}
const focusableSelector = [
'button:not([disabled])',
'textarea:not([disabled])',
'input:not([disabled])',
'select:not([disabled])',
'[href]',
'[tabindex]:not([tabindex="-1"])',
].join(',')
export function ConfirmActionDialog({
open,
title,
description,
confirmLabel = '确认',
loadingLabel = '处理中...',
cancelLabel = '取消',
tone = 'primary',
reason,
loading,
error,
reasonValue,
onReasonChange,
onCancel,
onConfirm,
}: ConfirmActionDialogProps) {
const titleId = useId()
const descriptionId = useId()
const backdropRef = useRef<HTMLDivElement>(null)
const dialogRef = useRef<HTMLDivElement>(null)
const reasonRef = useRef<HTMLTextAreaElement>(null)
const cancelRef = useRef<HTMLButtonElement>(null)
useEffect(() => {
if (!open) return
const previousActiveElement = document.activeElement instanceof HTMLElement
? document.activeElement
: null
const previousOverflow = document.body.style.overflow
document.body.style.overflow = 'hidden'
const backgroundElements = Array.from(document.body.children)
.filter((element): element is HTMLElement => (
element instanceof HTMLElement && element !== backdropRef.current
))
.map((element) => ({
element,
inert: element.inert,
ariaHidden: element.getAttribute('aria-hidden'),
}))
backgroundElements.forEach(({ element }) => {
element.inert = true
element.setAttribute('aria-hidden', 'true')
})
const frame = window.requestAnimationFrame(() => {
if (reason) reasonRef.current?.focus()
else cancelRef.current?.focus()
})
return () => {
window.cancelAnimationFrame(frame)
document.body.style.overflow = previousOverflow
backgroundElements.forEach(({ element, inert, ariaHidden }) => {
element.inert = inert
if (ariaHidden === null) element.removeAttribute('aria-hidden')
else element.setAttribute('aria-hidden', ariaHidden)
})
previousActiveElement?.focus()
}
}, [open, reason])
useEffect(() => {
if (open && loading) dialogRef.current?.focus()
}, [loading, open])
if (!open || typeof document === 'undefined') return null
const reasonRequired = Boolean(reason?.required)
const confirmDisabled = reasonRequired && !reasonValue.trim()
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
if (event.key === 'Escape' && !loading) {
event.preventDefault()
onCancel()
return
}
if (event.key !== 'Tab') return
const focusable = Array.from(
dialogRef.current?.querySelectorAll<HTMLElement>(focusableSelector) || [],
)
if (focusable.length === 0) {
event.preventDefault()
dialogRef.current?.focus()
return
}
const first = focusable[0]
const last = focusable[focusable.length - 1]
if (event.shiftKey && document.activeElement === first) {
event.preventDefault()
last.focus()
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault()
first.focus()
}
}
return createPortal(
<div
ref={backdropRef}
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/50 p-4"
onMouseDown={(event) => {
if (!loading && event.target === event.currentTarget) onCancel()
}}
>
<div
ref={dialogRef}
role={tone === 'danger' ? 'alertdialog' : 'dialog'}
aria-modal="true"
aria-labelledby={titleId}
aria-describedby={descriptionId}
aria-busy={loading}
tabIndex={-1}
onKeyDown={handleKeyDown}
className="max-h-[calc(100dvh-2rem)] w-full max-w-md overflow-y-auto rounded-lg bg-white text-[#1a1c1c] outline-none"
>
<div className="border-b border-[#e2e2e2] px-5 py-4">
<div className="flex items-start gap-3">
<span
className={`mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-full ${
tone === 'danger'
? 'bg-[#fff0ee] text-[#b42318]'
: 'bg-[#edf3ff] text-[#0050cb]'
}`}
aria-hidden="true"
>
{tone === 'danger' ? (
<svg className="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v4m0 4h.01M10.3 3.7 2.6 17a2 2 0 0 0 1.7 3h15.4a2 2 0 0 0 1.7-3L13.7 3.7a2 2 0 0 0-3.4 0Z" />
</svg>
) : (
<svg className="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9h.01M11 12h1v4h1m8-4a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
</svg>
)}
</span>
<div className="min-w-0">
<h2 id={titleId} className="text-[16px] font-bold leading-6">
{title}
</h2>
<div id={descriptionId} className="mt-1 text-[13px] leading-6 text-[#5f5e5e]">
{description}
</div>
</div>
</div>
</div>
{reason && (
<div className="px-5 pt-4">
<label className="grid gap-2 text-[13px] font-semibold text-[#1a1c1c]">
<span>
{reason.label || '操作原因'}
{reasonRequired && <span className="ml-1 text-[#b42318]">*</span>}
</span>
<textarea
ref={reasonRef}
value={reasonValue}
onChange={(event) => onReasonChange(event.target.value)}
required={reasonRequired}
disabled={loading}
maxLength={500}
rows={3}
placeholder={reason.placeholder || '请填写操作原因'}
className="w-full resize-y rounded border border-[#c2c6d8] px-3 py-2 text-[13px] font-normal leading-5 text-[#1a1c1c] outline-none placeholder:text-[#676767] focus:border-[#0050cb] focus:ring-2 focus:ring-[#dbe6ff]"
/>
</label>
<p className="mt-1.5 text-[12px] leading-5 text-[#5f5e5e]">
{reason.helperText || '原因将记录在管理操作日志中。'}
</p>
</div>
)}
{error && (
<div role="alert" className="mx-5 mt-4 rounded border border-[#f0b8b8] bg-[#fff5f5] px-3 py-2 text-[12px] leading-5 text-[#93000a]">
{error}
</div>
)}
<div className="flex flex-col-reverse gap-2 px-5 py-4 sm:flex-row sm:justify-end">
<button
ref={cancelRef}
type="button"
disabled={loading}
onClick={onCancel}
className="min-h-11 rounded border border-[#c2c6d8] px-4 text-[13px] font-bold text-[#1a1c1c] hover:bg-[#f5f5f5] focus:outline-none focus:ring-2 focus:ring-[#dbe6ff] disabled:cursor-not-allowed disabled:opacity-45"
>
{cancelLabel}
</button>
<button
type="button"
disabled={confirmDisabled || loading}
onClick={onConfirm}
className={`min-h-11 rounded px-4 text-[13px] font-bold text-white focus:outline-none focus:ring-2 disabled:cursor-not-allowed disabled:opacity-45 ${
tone === 'danger'
? 'bg-[#b42318] hover:bg-[#931f16] focus:ring-[#ffd5d0]'
: 'bg-[#0050cb] hover:bg-[#003f9e] focus:ring-[#dbe6ff]'
}`}
>
{loading ? loadingLabel : confirmLabel}
</button>
</div>
</div>
</div>,
document.body,
)
}
export function useConfirmActionDialog() {
const [pending, setPending] = useState<ConfirmActionOptions | null>(null)
const [reasonValue, setReasonValue] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const resolverRef = useRef<((result: ConfirmActionResult) => void) | null>(null)
const finish = useCallback((result: ConfirmActionResult) => {
const resolve = resolverRef.current
resolverRef.current = null
setPending(null)
setReasonValue('')
setLoading(false)
setError(null)
resolve?.(result)
}, [])
const confirmAction = useCallback((options: ConfirmActionOptions) => (
new Promise<ConfirmActionResult>((resolve) => {
resolverRef.current?.({ confirmed: false, reason: '' })
resolverRef.current = resolve
setReasonValue('')
setLoading(false)
setError(null)
setPending(options)
})
), [])
useEffect(() => () => {
resolverRef.current?.({ confirmed: false, reason: '' })
resolverRef.current = null
}, [])
const confirmPendingAction = useCallback(async () => {
if (!pending) return
const reason = reasonValue.trim()
if (pending.reason?.required && !reason) return
if (!pending.action) {
finish({ confirmed: true, reason })
return
}
setLoading(true)
setError(null)
try {
await pending.action(reason)
finish({ confirmed: true, reason })
} catch (actionError) {
setError(actionError instanceof Error ? actionError.message : '操作失败,请稍后重试')
setLoading(false)
}
}, [finish, pending, reasonValue])
const confirmActionDialog = pending ? (
<ConfirmActionDialog
{...pending}
open
loading={loading}
error={error}
reasonValue={reasonValue}
onReasonChange={setReasonValue}
onCancel={() => finish({ confirmed: false, reason: '' })}
onConfirm={() => void confirmPendingAction()}
/>
) : null
return { confirmAction, confirmActionDialog }
}