fix(web): add 60s grace period undo, executing banner and ttl safeguards to interactive card

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
WeiCHEN
2026-09-08 18:45:10 +08:00
co-authored by Cursor
parent 1c62be6e49
commit 649290bbba
2 changed files with 421 additions and 11 deletions
@@ -1,12 +1,16 @@
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, it, expect } from 'vitest';
import { InteractiveCardData, CardActionItem } from '../../types/workbench';
import {
InteractiveCard,
getCardTypeMeta,
formatResolutionSummary,
formatStatusInfo,
isCardActionable,
resolveRiskBadge,
computeCardState,
calculateRemainingSeconds,
} from './InteractiveCard';
describe('InteractiveCard Logic', () => {
@@ -142,6 +146,207 @@ describe('InteractiveCard Logic', () => {
const state4 = computeCardState('outdated', null);
expect(state4.effectiveStatus).toBe('outdated');
expect(state4.canClick).toBe(false);
// 撤回操作触发局部 executing 状态(即便外部卡片当前仍为 resolved)
const state5 = computeCardState('resolved', 'executing');
expect(state5.effectiveStatus).toBe('executing');
expect(state5.canClick).toBe(false);
});
});
describe('Grace Period Undo and 60s Countdown', () => {
it('should calculate remaining seconds correctly based on timestamps', () => {
const now = 1700000000000;
// 20 秒前生效:应剩余 40 秒
const resolved20sAgo = new Date(now - 20000).toISOString();
expect(calculateRemainingSeconds(resolved20sAgo, null, 60, now)).toBe(40);
// 60 秒前生效:应剩余 0 秒
const resolved60sAgo = new Date(now - 60000).toISOString();
expect(calculateRemainingSeconds(resolved60sAgo, null, 60, now)).toBe(0);
// 75 秒前生效(已超时):应归零
const resolved75sAgo = new Date(now - 75000).toISOString();
expect(calculateRemainingSeconds(resolved75sAgo, null, 60, now)).toBe(0);
// 当 resolvedAt 无法解析时,使用 localResolvedAt 回退时间戳
const localFallback = now - 15000;
expect(calculateRemainingSeconds('16:10', localFallback, 60, now)).toBe(45);
// 无任何时间戳时默认返回完整 60 秒
expect(calculateRemainingSeconds(undefined, null, 60, now)).toBe(60);
});
it('should render undo bar within grace period and hide it when expired', () => {
// 1. 处于 60 秒合法窗口期内的已驳回卡片 (例如生效于 10 秒前)
const recentCard: InteractiveCardData = {
cardId: 'c-undo-1',
cardType: 'CONFIRMATION',
title: '审核驳回流转确认',
status: 'resolved',
ttlTurns: 3,
actions: [],
resolutionSummary: '已正式退回编制阶段',
resolvedBy: '李主管',
resolvedAt: new Date(Date.now() - 10000).toISOString(),
};
const htmlWithUndo = renderToStaticMarkup(
React.createElement(InteractiveCard, { card: recentCard })
);
expect(htmlWithUndo).toContain('撤回驳回 (恢复为待审核,剩余');
expect(htmlWithUndo).toContain('已生效');
// 2. 超过 60 秒窗口期的已生效卡片 (例如生效于 70 秒前)
const expiredWindowCard: InteractiveCardData = {
cardId: 'c-undo-2',
cardType: 'CONFIRMATION',
title: '审核驳回流转确认',
status: 'resolved',
ttlTurns: 3,
actions: [],
resolutionSummary: '已正式退回编制阶段',
resolvedBy: '李主管',
resolvedAt: new Date(Date.now() - 70000).toISOString(),
};
const htmlWithoutUndo = renderToStaticMarkup(
React.createElement(InteractiveCard, { card: expiredWindowCard })
);
expect(htmlWithoutUndo).not.toContain('撤回驳回');
expect(htmlWithoutUndo).toContain('已生效');
});
});
describe('ttlTurns <= 0 Safeguards and Expiration', () => {
it('should defensively evaluate effective status as expired when ttlTurns <= 0', () => {
// 1. ttlTurns === 0
const stateZero = computeCardState('active', null, 0);
expect(stateZero.effectiveStatus).toBe('expired');
expect(stateZero.canClick).toBe(false);
// 2. ttlTurns < 0
const stateNegative = computeCardState('active', null, -2);
expect(stateNegative.effectiveStatus).toBe('expired');
expect(stateNegative.canClick).toBe(false);
// 3. ttlTurns > 0 保持 active
const stateNormal = computeCardState('active', null, 3);
expect(stateNormal.effectiveStatus).toBe('active');
expect(stateNormal.canClick).toBe(true);
// 4. 支持对象入参重载
const stateObj = computeCardState({ status: 'active', ttlTurns: 0 });
expect(stateObj.effectiveStatus).toBe('expired');
expect(stateObj.canClick).toBe(false);
});
it('should render expired banner when card ttlTurns <= 0', () => {
const expiredTtlCard: InteractiveCardData = {
cardId: 'c-ttl-0',
cardType: 'DIFF_PROPOSAL',
title: '差异变更提案',
status: 'active',
ttlTurns: 0,
actions: [
{ actionId: 'apply', label: '采纳变更', style: 'primary', isAffirmativeDefault: true },
],
};
const html = renderToStaticMarkup(
React.createElement(InteractiveCard, { card: expiredTtlCard })
);
expect(html).toContain('⏱️ 已超时失效');
// 超时后操作按钮区不得渲染
expect(html).not.toContain('采纳变更');
});
});
describe('Executing Banner and Lock Safeguards', () => {
it('should render executing banner when card is in executing status', () => {
const executingCard: InteractiveCardData = {
cardId: 'c-exec-1',
cardType: 'CONFIRMATION',
title: '审核驳回流转确认',
status: 'executing',
ttlTurns: 3,
actions: [
{ actionId: 'confirm', label: '确认驳回', style: 'danger' },
],
};
const html = renderToStaticMarkup(
React.createElement(InteractiveCard, { card: executingCard })
);
expect(html).toContain('⚡ 操作执行中,防重锁定中...');
expect(html).toContain('执行中');
});
});
describe('CSS Variable Syntax and Outline Styling', () => {
it('should render valid CSS variable border for risk badges without hex alpha suffix', () => {
const highRiskCard: InteractiveCardData = {
cardId: 'c-risk-1',
cardType: 'REMINDER_NOTICE',
title: '高合规风险预警',
impactRisk: '高风险:强监管条款',
status: 'active',
ttlTurns: 2,
actions: [],
};
const html = renderToStaticMarkup(
React.createElement(InteractiveCard, { card: highRiskCard })
);
// 必须是合法的 CSS 变量,严禁出现 var(--danger)33 等语法错误
expect(html).toContain('border:1px solid var(--danger)');
expect(html).not.toContain('var(--danger)33');
});
it('should apply matched outline colors for affirmative default buttons', () => {
// 危险类型默认肯定按钮 (danger outline)
const dangerCard: InteractiveCardData = {
cardId: 'c-outline-danger',
cardType: 'CONFIRMATION',
title: '确认驳回',
status: 'active',
ttlTurns: 3,
actions: [
{
actionId: 'reject',
label: '确认驳回',
style: 'danger',
isAffirmativeDefault: true,
},
],
};
const htmlDanger = renderToStaticMarkup(
React.createElement(InteractiveCard, { card: dangerCard })
);
expect(htmlDanger).toContain('outline:1.5px solid var(--danger)');
// 主品牌色默认肯定按钮 (brand-500 outline)
const primaryCard: InteractiveCardData = {
cardId: 'c-outline-primary',
cardType: 'CONFIRMATION',
title: '确认流转',
status: 'active',
ttlTurns: 3,
actions: [
{
actionId: 'pass',
label: '确认通过',
style: 'primary',
isAffirmativeDefault: true,
},
],
};
const htmlPrimary = renderToStaticMarkup(
React.createElement(InteractiveCard, { card: primaryCard })
);
expect(htmlPrimary).toContain('outline:1.5px solid var(--brand-500)');
});
});
+216 -11
View File
@@ -17,6 +17,7 @@ import {
FileQuestion,
CornerDownRight,
Check,
RotateCcw,
} from 'lucide-react';
export interface InteractiveCardProps {
@@ -172,17 +173,64 @@ export function isCardActionable(status: CardActionStatus): boolean {
}
/**
* 计算卡片当前实际生效状态与点击可用性(防重保护)
* 计算卡片当前实际生效状态与点击可用性(防重保护与防御性超时判定)
*/
export function computeCardState(
cardStatus: CardActionStatus,
localStatus: CardActionStatus | null
cardOrStatus: CardActionStatus | { status: CardActionStatus; ttlTurns?: number },
localStatus?: CardActionStatus | null,
ttlTurns?: number
): { effectiveStatus: CardActionStatus; canClick: boolean } {
const effectiveStatus = cardStatus === 'active' && localStatus ? localStatus : cardStatus;
let cardStatus: CardActionStatus;
let turns: number | undefined = ttlTurns;
if (typeof cardOrStatus === 'object' && cardOrStatus !== null) {
cardStatus = cardOrStatus.status;
turns = cardOrStatus.ttlTurns;
} else {
cardStatus = cardOrStatus;
}
// 1. 本地执行中状态优先(脉冲防重锁定)
if (localStatus === 'executing') {
return { effectiveStatus: 'executing', canClick: false };
}
// 2. ttlTurns <= 0 防御性超时判定
if (turns !== undefined && turns <= 0 && cardStatus === 'active') {
return { effectiveStatus: 'expired', canClick: false };
}
const effectiveStatus = localStatus || cardStatus;
const canClick = effectiveStatus === 'active';
return { effectiveStatus, canClick };
}
/**
* 计算 60 秒合法窗口期撤销机制剩余秒数 (Grace Period Undo)
*/
export function calculateRemainingSeconds(
resolvedAt?: string,
localResolvedAt?: number | null,
gracePeriod: number = 60,
now: number = Date.now()
): number {
let baseMs: number | null = null;
if (resolvedAt) {
const parsed = Date.parse(resolvedAt);
if (!isNaN(parsed) && parsed > 0) {
baseMs = parsed;
}
}
if (!baseMs && localResolvedAt) {
baseMs = localResolvedAt;
}
if (!baseMs) {
return gracePeriod;
}
const elapsedSec = Math.floor((now - baseMs) / 1000);
return Math.max(0, gracePeriod - elapsedSec);
}
/**
* 解析合规风险徽章
*/
@@ -230,16 +278,77 @@ export const InteractiveCard: React.FC<InteractiveCardProps> = ({
}) => {
const [localStatus, setLocalStatus] = useState<CardActionStatus | null>(null);
const [executingActionId, setExecutingActionId] = useState<string | null>(null);
const [localResolvedTimestamp, setLocalResolvedTimestamp] = useState<number | null>(null);
const [remainingSeconds, setRemainingSeconds] = useState<number>(() => {
if (card.status === 'resolved') {
return calculateRemainingSeconds(card.resolvedAt, null, 60, Date.now());
}
return 0;
});
const prevCardIdRef = React.useRef(card.cardId);
const prevStatusRef = React.useRef(card.status);
// 同步外部 card.status 变化,重置本地临时执行态
// 同步外部 card.cardId 与 card.status 变化,重置本地临时执行态与窗口期
useEffect(() => {
if (card.status !== 'active') {
const cardIdChanged = prevCardIdRef.current !== card.cardId;
const statusChanged = prevStatusRef.current !== card.status;
prevCardIdRef.current = card.cardId;
prevStatusRef.current = card.status;
if (cardIdChanged) {
setLocalStatus(null);
setExecutingActionId(null);
setLocalResolvedTimestamp(null);
setRemainingSeconds(
card.status === 'resolved'
? calculateRemainingSeconds(card.resolvedAt, null, 60, Date.now())
: 0
);
} else if (statusChanged) {
setLocalStatus(null);
setExecutingActionId(null);
}
}, [card.status]);
}, [card.cardId, card.status, card.resolvedAt]);
const { effectiveStatus, canClick } = computeCardState(card.status, localStatus);
// 60 秒合法窗口期撤销倒计时机制 (Grace Period Undo)
useEffect(() => {
if (card.status !== 'resolved') {
setRemainingSeconds(0);
return;
}
let baseTs: number;
if (card.resolvedAt && !isNaN(Date.parse(card.resolvedAt)) && Date.parse(card.resolvedAt) > 0) {
baseTs = Date.parse(card.resolvedAt);
} else {
if (!localResolvedTimestamp) {
const now = Date.now();
setLocalResolvedTimestamp(now);
baseTs = now;
} else {
baseTs = localResolvedTimestamp;
}
}
const currentRem = calculateRemainingSeconds(card.resolvedAt, baseTs, 60, Date.now());
setRemainingSeconds(currentRem);
if (currentRem <= 0) {
return;
}
const timer = setInterval(() => {
const rem = calculateRemainingSeconds(card.resolvedAt, baseTs, 60, Date.now());
setRemainingSeconds(rem);
if (rem <= 0) {
clearInterval(timer);
}
}, 1000);
return () => clearInterval(timer);
}, [card.status, card.resolvedAt, localResolvedTimestamp]);
const { effectiveStatus, canClick } = computeCardState(card.status, localStatus, card.ttlTurns);
const typeMeta = getCardTypeMeta(card.cardType);
const statusInfo = formatStatusInfo(effectiveStatus);
const resolution = formatResolutionSummary(card);
@@ -257,6 +366,20 @@ export const InteractiveCard: React.FC<InteractiveCardProps> = ({
onExecuteAction(card.cardId, action.actionId, action.payload);
};
const handleUndoClick = () => {
if (!onExecuteAction || statusInfo.isExecuting) return;
// 激活局部防重执行态
setLocalStatus('executing');
setExecutingActionId('undo');
onExecuteAction(card.cardId, 'undo', {
cardId: card.cardId,
action: 'undo',
targetDocId: card.targetDocId,
});
};
return (
<div
className={className}
@@ -317,6 +440,26 @@ export const InteractiveCard: React.FC<InteractiveCardProps> = ({
{/* 状态徽标 / TTL 轮次 / 印章 */}
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', flexShrink: 0 }}>
{statusInfo.isExecuting && (
<div
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '4px',
fontSize: '11px',
fontWeight: 600,
color: 'var(--brand-600)',
background: 'var(--brand-50)',
border: '1px solid var(--brand-100)',
borderRadius: 'var(--r-sm)',
padding: '2px 8px',
}}
>
<Loader2 size={11} className="animate-spin-fast" />
<span>执行中</span>
</div>
)}
{statusInfo.isActive && card.ttlTurns !== undefined && (
<span
style={{
@@ -440,7 +583,7 @@ export const InteractiveCard: React.FC<InteractiveCardProps> = ({
borderRadius: 'var(--r-sm)',
color: riskBadge.color,
background: riskBadge.bg,
border: `1px solid ${riskBadge.color}33`,
border: '1px solid ' + riskBadge.color,
alignSelf: 'flex-start',
}}
>
@@ -449,7 +592,27 @@ export const InteractiveCard: React.FC<InteractiveCardProps> = ({
</div>
)}
{/* 状态横幅展示:已生效 / 已失效 / 已超时 / 已放弃 */}
{/* 状态横幅展示:执行中 / 已生效 / 已失效 / 已超时 / 已放弃 */}
{statusInfo.isExecuting && (
<div
style={{
padding: '7px 10px',
borderRadius: 'var(--r-sm)',
background: 'var(--brand-50)',
border: '1px solid var(--brand-100)',
color: 'var(--brand-600)',
fontSize: '11.5px',
display: 'flex',
alignItems: 'center',
gap: '6px',
fontWeight: 500,
}}
>
<Loader2 size={13} className="animate-spin-fast" />
<span>⚡ 操作执行中,防重锁定中...</span>
</div>
)}
{statusInfo.isResolved && (
<div
style={{
@@ -482,6 +645,44 @@ export const InteractiveCard: React.FC<InteractiveCardProps> = ({
{resolution.time && <span>生效时间: {resolution.time}</span>}
</div>
)}
{/* 60 秒合法窗口期撤销机制 (Grace Period Undo) */}
{remainingSeconds > 0 && (
<div
style={{
marginTop: '4px',
paddingTop: '6px',
borderTop: '1px dashed var(--border-light, rgba(0, 0, 0, 0.08))',
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-end',
}}
>
<button
type="button"
onClick={handleUndoClick}
disabled={statusInfo.isExecuting}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '4px',
padding: '3px 8px',
borderRadius: 'var(--r-sm)',
fontSize: '11px',
fontWeight: 500,
background: 'var(--bg-card)',
border: '1px solid var(--border)',
color: 'var(--text-muted)',
cursor: statusInfo.isExecuting ? 'not-allowed' : 'pointer',
transition: 'all 0.15s ease',
}}
title="在60秒窗口期内撤回操作并恢复待审核状态"
>
<RotateCcw size={11} />
<span>撤回驳回 (恢复为待审核,剩余 {remainingSeconds}s)</span>
</button>
</div>
)}
</div>
)}
@@ -602,7 +803,11 @@ export const InteractiveCard: React.FC<InteractiveCardProps> = ({
cursor: canClick && !statusInfo.isExecuting ? 'pointer' : 'not-allowed',
opacity: statusInfo.isExecuting ? 0.65 : 1,
transition: 'opacity 0.2s ease, transform 0.1s ease',
outline: act.isAffirmativeDefault ? '1.5px solid var(--brand-500)' : 'none',
outline: act.isAffirmativeDefault
? act.style === 'danger'
? '1.5px solid var(--danger)'
: '1.5px solid var(--brand-500)'
: 'none',
outlineOffset: act.isAffirmativeDefault ? '1px' : '0',
}}
>