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:
@@ -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)');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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',
|
||||
}}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user