fix(web): pending-approvals-card 加防重複點擊 + loading 狀態

linter 自動強化: actioningId state 防止同一張卡重複操作
- disabled + opacity 0.6 + cursor not-allowed
- loading 時按鈕顯示 '...'
- finally() 確保 actioningId 清除

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
OG T
2026-04-09 18:38:08 +08:00
parent 890e2a9568
commit 896bef94ee
7 changed files with 89 additions and 48 deletions

View File

@@ -41,6 +41,7 @@ export function CompliancePanel() {
if (s) setSummary(s)
if (r) setRepairStats(r)
})
.catch(() => setError('load_failed'))
.finally(() => setLoading(false))
}, [])

View File

@@ -48,6 +48,7 @@ export function SecurityPanel() {
if (statsData) setStats(statsData)
setIssues(listData?.issues ?? [])
})
.catch(() => setError('load_failed'))
.finally(() => setLoading(false))
}, [])
@@ -71,7 +72,7 @@ export function SecurityPanel() {
{[
{ label: t('totalIssues'), value: stats.total_issues, color: '#141413' },
{ label: t('criticalIssues'), value: stats.critical_count, color: stats.critical_count > 0 ? '#cc2200' : '#22C55E' },
{ label: 'Unresolved', value: stats.unresolved_issues, color: stats.unresolved_issues > 0 ? '#F59E0B' : '#22C55E' },
{ label: t('unresolvedIssues'), value: stats.unresolved_issues, color: stats.unresolved_issues > 0 ? '#F59E0B' : '#22C55E' },
{ label: t('errorRate'), value: stats.error_count_24h != null ? `${stats.error_count_24h}/24h` : '—', color: '#141413' },
].map(card => (
<div key={card.label} style={cardStyle}>

View File

@@ -35,6 +35,7 @@ export function PendingApprovalsCard() {
const locale = useLocale()
const [approvals, setApprovals] = useState<Approval[]>([])
const [actionError, setActionError] = useState<string | null>(null)
const [actioningId, setActioningId] = useState<string | null>(null)
useEffect(() => {
fetch(`${API_BASE}/api/v1/approvals/pending`)
@@ -78,24 +79,30 @@ export function PendingApprovalsCard() {
</div>
<div style={{ display: 'flex', gap: 4, marginTop: 5 }}>
<button
disabled={actioningId === ap.id}
onClick={() => {
setActionError(null)
setActioningId(ap.id)
fetch(`${API_BASE}/api/v1/approvals/${ap.id}/sign`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ signer: 'web-ui' }) })
.then(r => { if (!r.ok) throw new Error(`${r.status}`); return r })
.then(() => setApprovals(prev => prev.filter(x => x.id !== ap.id)))
.catch(e => setActionError(`approve failed: ${e.message}`))
.finally(() => setActioningId(null))
}}
style={{ flex: 1, padding: 6, border: 'none', borderRadius: 5, fontSize: 11, fontWeight: 600, cursor: 'pointer', background: '#22C55E', color: '#fff' }}
>{t('approve')}</button>
style={{ flex: 1, padding: 6, border: 'none', borderRadius: 5, fontSize: 11, fontWeight: 600, cursor: actioningId === ap.id ? 'not-allowed' : 'pointer', background: '#22C55E', color: '#fff', opacity: actioningId === ap.id ? 0.6 : 1 }}
>{actioningId === ap.id ? '...' : t('approve')}</button>
<button
disabled={actioningId === ap.id}
onClick={() => {
setActionError(null)
setActioningId(ap.id)
fetch(`${API_BASE}/api/v1/approvals/${ap.id}/reject`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ reason: 'rejected-from-web' }) })
.then(r => { if (!r.ok) throw new Error(`${r.status}`); return r })
.then(() => setApprovals(prev => prev.filter(x => x.id !== ap.id)))
.catch(e => setActionError(`reject failed: ${e.message}`))
.finally(() => setActioningId(null))
}}
style={{ flex: 1, padding: 6, border: '0.5px solid #e0ddd4', borderRadius: 5, fontSize: 11, cursor: 'pointer', background: '#fff', color: '#87867f' }}
style={{ flex: 1, padding: 6, border: '0.5px solid #e0ddd4', borderRadius: 5, fontSize: 11, cursor: actioningId === ap.id ? 'not-allowed' : 'pointer', background: '#fff', color: '#87867f', opacity: actioningId === ap.id ? 0.6 : 1 }}
>{t('reject')}</button>
</div>
</div>