diff --git a/web/src/components/chat/InteractiveCard.test.ts b/web/src/components/chat/InteractiveCard.test.ts index 62223786..02e92227 100644 --- a/web/src/components/chat/InteractiveCard.test.ts +++ b/web/src/components/chat/InteractiveCard.test.ts @@ -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)'); }); }); diff --git a/web/src/components/chat/InteractiveCard.tsx b/web/src/components/chat/InteractiveCard.tsx index 10cd263f..bab23bd8 100644 --- a/web/src/components/chat/InteractiveCard.tsx +++ b/web/src/components/chat/InteractiveCard.tsx @@ -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 = ({ }) => { const [localStatus, setLocalStatus] = useState(null); const [executingActionId, setExecutingActionId] = useState(null); + const [localResolvedTimestamp, setLocalResolvedTimestamp] = useState(null); + const [remainingSeconds, setRemainingSeconds] = useState(() => { + 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 = ({ 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 (
= ({ {/* 状态徽标 / TTL 轮次 / 印章 */}
+ {statusInfo.isExecuting && ( +
+ + 执行中 +
+ )} + {statusInfo.isActive && card.ttlTurns !== undefined && ( = ({ 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 = ({
)} - {/* 状态横幅展示:已生效 / 已失效 / 已超时 / 已放弃 */} + {/* 状态横幅展示:执行中 / 已生效 / 已失效 / 已超时 / 已放弃 */} + {statusInfo.isExecuting && ( +
+ + ⚡ 操作执行中,防重锁定中... +
+ )} + {statusInfo.isResolved && (
= ({ {resolution.time && 生效时间: {resolution.time}}
)} + + {/* 60 秒合法窗口期撤销机制 (Grace Period Undo) */} + {remainingSeconds > 0 && ( +
+ +
+ )}
)} @@ -602,7 +803,11 @@ export const InteractiveCard: React.FC = ({ 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', }} >