Files
Agent-System/web/src/components/chat/InteractiveCard.tsx
T
weichenandCursor d865342ae2 feat(web): replace the standing trace panel with a chat card
The requirements and impact summary no longer occupies the chat column. Open recheck marks arrive as an assistant card with a no-change action.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-21 05:19:43 +00:00

863 lines
26 KiB
TypeScript

import React, { useState, useEffect } from 'react';
import {
InteractiveCardData,
InteractiveCardType,
CardActionStatus,
CardActionItem,
} from '../../types/workbench';
import {
CheckCircle2,
AlertTriangle,
Clock,
Ban,
Loader2,
ShieldAlert,
FileText,
GitCompare,
FileQuestion,
CornerDownRight,
Check,
RotateCcw,
} from 'lucide-react';
export interface InteractiveCardProps {
card: InteractiveCardData;
onExecuteAction?: (cardId: string, actionId: string, payload?: Record<string, string> | any) => void;
className?: string;
style?: React.CSSProperties;
}
/**
* 获取卡片类型的元数据与样式配置
*/
export function getCardTypeMeta(type: InteractiveCardType) {
switch (type) {
case 'CONFIRMATION':
return {
label: '流转确认',
badgeText: '确认门禁',
icon: CheckCircle2,
accentColor: 'var(--brand-500)',
badgeBg: 'var(--brand-50)',
badgeColor: 'var(--brand-600)',
badgeBorder: 'var(--brand-100)',
};
case 'PLAN_CONFIRM':
return {
label: '计划确认',
badgeText: '多步骤计划',
icon: CheckCircle2,
accentColor: 'var(--brand-500)',
badgeBg: 'var(--brand-50)',
badgeColor: 'var(--brand-600)',
badgeBorder: 'var(--brand-100)',
};
case 'SLOT_FILLING':
return {
label: '要素补全',
badgeText: '要素补全',
icon: FileQuestion,
accentColor: '#7C3AED',
badgeBg: 'rgba(124, 58, 237, 0.08)',
badgeColor: '#7C3AED',
badgeBorder: 'rgba(124, 58, 237, 0.25)',
};
case 'DIFF_PROPOSAL':
return {
label: '变更提案',
badgeText: '变更提案',
icon: GitCompare,
accentColor: 'var(--amber-500)',
badgeBg: 'var(--amber-50)',
badgeColor: 'var(--amber-600)',
badgeBorder: 'rgba(242, 169, 59, 0.3)',
};
case 'REMINDER_NOTICE':
return {
label: '合规提醒',
badgeText: '合规提醒',
icon: AlertTriangle,
accentColor: 'var(--info)',
badgeBg: 'var(--info-bg)',
badgeColor: 'var(--info)',
badgeBorder: 'rgba(43, 108, 201, 0.2)',
};
case 'RECHECK_NOTICE':
return {
label: '待复核',
badgeText: '待复核',
icon: AlertTriangle,
accentColor: 'var(--amber-500)',
badgeBg: 'var(--amber-50)',
badgeColor: 'var(--amber-600)',
badgeBorder: 'rgba(242, 169, 59, 0.3)',
};
default:
return {
label: '协同卡片',
badgeText: '交互门禁',
icon: CheckCircle2,
accentColor: 'var(--brand-500)',
badgeBg: 'var(--brand-50)',
badgeColor: 'var(--brand-600)',
badgeBorder: 'var(--brand-100)',
};
}
}
/**
* 格式化已生效状态摘要文本
*/
export function formatResolutionSummary(card: InteractiveCardData) {
return {
summary: card.resolutionSummary || '操作已执行并生效',
operator: card.resolvedBy || '编制负责人',
time: card.resolvedAt || '',
};
}
/**
* 格式化状态标识与横幅信息
*/
export function formatStatusInfo(status: CardActionStatus) {
switch (status) {
case 'resolved':
return {
badgeText: '✅ 已生效',
isResolved: true,
isOutdated: false,
isExpired: false,
isCancelled: false,
isExecuting: false,
isActive: false,
};
case 'outdated':
return {
badgeText: '⚠️ 文档状态已变更,操作失效',
isResolved: false,
isOutdated: true,
isExpired: false,
isCancelled: false,
isExecuting: false,
isActive: false,
};
case 'expired':
return {
badgeText: '⏱️ 已超时失效',
isResolved: false,
isOutdated: false,
isExpired: true,
isCancelled: false,
isExecuting: false,
isActive: false,
};
case 'cancelled':
return {
badgeText: '🚫 已放弃',
isResolved: false,
isOutdated: false,
isExpired: false,
isCancelled: true,
isExecuting: false,
isActive: false,
};
case 'executing':
return {
badgeText: '⚡ 操作执行中,防重锁定中...',
isResolved: false,
isOutdated: false,
isExpired: false,
isCancelled: false,
isExecuting: true,
isActive: false,
};
case 'active':
default:
return {
badgeText: '待确认',
isResolved: false,
isOutdated: false,
isExpired: false,
isCancelled: false,
isExecuting: false,
isActive: true,
};
}
}
/**
* 判断卡片是否处于可交互激活态
*/
export function isCardActionable(status: CardActionStatus): boolean {
return status === 'active';
}
/**
* 计算卡片当前实际生效状态与点击可用性(防重保护与防御性超时判定)
*/
export function computeCardState(
cardOrStatus: CardActionStatus | { status: CardActionStatus; ttlTurns?: number },
localStatus?: CardActionStatus | null,
ttlTurns?: number
): { effectiveStatus: CardActionStatus; canClick: boolean } {
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);
}
/**
* 解析合规风险徽章
*/
export function resolveRiskBadge(impactRisk?: string): {
level: 'high' | 'medium' | 'low' | 'none';
color: string;
bg: string;
label: string;
} {
if (!impactRisk) {
return { level: 'none', color: '', bg: '', label: '' };
}
if (impactRisk.includes('高') || impactRisk.toLowerCase().includes('high')) {
return {
level: 'high',
color: 'var(--danger)',
bg: 'var(--danger-bg)',
label: impactRisk,
};
}
if (impactRisk.includes('中') || impactRisk.toLowerCase().includes('medium')) {
return {
level: 'medium',
color: 'var(--amber-600)',
bg: 'var(--amber-50)',
label: impactRisk,
};
}
return {
level: 'low',
color: 'var(--success)',
bg: 'var(--success-bg)',
label: impactRisk,
};
}
export const InteractiveCard: React.FC<InteractiveCardProps> = ({
card,
onExecuteAction,
className,
style,
}) => {
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.cardId 与 card.status 变化,重置本地临时执行态与窗口期
useEffect(() => {
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.cardId, card.status, card.resolvedAt]);
// 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);
const riskBadge = resolveRiskBadge(card.impactRisk);
const IconComponent = typeMeta.icon;
const handleActionClick = (action: CardActionItem) => {
if (!canClick || !onExecuteAction) return;
// 激活局部防重脉冲锁定
setLocalStatus('executing');
setExecutingActionId(action.actionId);
// 触发外部操作回调
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}
role={card.cardType === 'PLAN_CONFIRM' ? 'status' : undefined}
aria-live={card.cardType === 'PLAN_CONFIRM' ? 'polite' : undefined}
data-testid={card.cardType === 'PLAN_CONFIRM' ? 'plan-confirm-card' : undefined}
data-plan-id={card.planId || undefined}
data-card-status={effectiveStatus}
style={{
marginTop: '10px',
padding: '12px 14px',
borderRadius: 'var(--r-md)',
background: 'var(--bg-card)',
border: '1px solid var(--border)',
borderLeft: `3.5px solid ${typeMeta.accentColor}`,
boxShadow: 'var(--shadow-sm)',
display: 'flex',
flexDirection: 'column',
gap: '9px',
position: 'relative',
overflow: 'hidden',
...style,
}}
>
{/* 头部:类型徽章、标题与 TTL 倒计时 / 已生效印章 */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: '8px',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', flexWrap: 'wrap' }}>
<span
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '4px',
fontSize: '11px',
fontWeight: 600,
padding: '2px 7px',
borderRadius: 'var(--r-pill)',
background: typeMeta.badgeBg,
color: typeMeta.badgeColor,
border: `1px solid ${typeMeta.badgeBorder}`,
}}
>
<IconComponent size={12} />
<span>{typeMeta.badgeText}</span>
</span>
<span
style={{
fontSize: '13px',
fontWeight: 700,
color: 'var(--text-strong)',
}}
>
{card.title}
</span>
</div>
{/* 状态徽标 / 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={{
fontSize: '10.5px',
color: 'var(--text-muted)',
background: 'var(--bg-subtle)',
padding: '1px 6px',
borderRadius: 'var(--r-pill)',
border: '1px solid var(--border)',
display: 'inline-flex',
alignItems: 'center',
gap: '3px',
}}
title={`有效期剩余 ${card.ttlTurns} 轮交互`}
>
<Clock size={11} style={{ color: 'var(--amber-500)' }} />
<span>剩余 {card.ttlTurns} 轮</span>
</span>
)}
{statusInfo.isResolved && (
<div
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '4px',
fontSize: '11px',
fontWeight: 700,
color: 'var(--card-resolved-text, var(--success))',
background: 'var(--card-resolved-bg, var(--success-bg))',
border: '1.5px solid var(--card-resolved-border, var(--success))',
borderRadius: 'var(--r-sm)',
padding: '2px 8px',
transform: 'rotate(-4deg)',
boxShadow: 'var(--card-stamp-shadow, 0 2px 6px rgba(33, 166, 106, 0.15))',
letterSpacing: '0.5px',
}}
>
<CheckCircle2 size={12} />
<span>已生效</span>
</div>
)}
</div>
</div>
{/* 锚定目标文档与章节 */}
{(card.targetDocTitle || card.targetDocId || card.sectionTitle || card.targetSectionId) && (
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: '3px',
fontSize: '11.5px',
color: 'var(--text-muted)',
background: 'var(--bg-subtle)',
padding: '6px 9px',
borderRadius: 'var(--r-sm)',
border: '1px solid var(--border-light)',
}}
>
{(card.targetDocTitle || card.targetDocId) && (
<div style={{ display: 'flex', alignItems: 'center', gap: '5px' }}>
<FileText size={12} style={{ color: typeMeta.accentColor, flexShrink: 0 }} />
<span>目标文档:</span>
<span style={{ color: 'var(--text-strong)', fontWeight: 600 }}>
{card.targetDocTitle || card.targetDocId}
</span>
</div>
)}
{(card.sectionTitle || card.targetSectionId) && (
<div style={{ display: 'flex', alignItems: 'center', gap: '5px', paddingLeft: '4px' }}>
<CornerDownRight size={11} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
<span>锚定段落:</span>
<span style={{ color: 'var(--text)', fontWeight: 500 }}>
{card.sectionTitle || card.targetSectionId}
</span>
</div>
)}
</div>
)}
{/* 拟建议修改内容 / 批注展示 */}
{card.proposedContent && (
<div
style={{
background: 'var(--bg-panel)',
padding: '8px 10px',
borderRadius: 'var(--r-sm)',
border: '1px solid var(--border)',
fontSize: '12px',
lineHeight: 1.5,
color: 'var(--text)',
}}
>
<div
style={{
fontSize: '11px',
color: 'var(--text-muted)',
marginBottom: '3px',
fontWeight: 600,
}}
>
{card.cardType === 'DIFF_PROPOSAL'
? '拟变更草案内容:'
: card.cardType === 'PLAN_CONFIRM'
? '计划步骤:'
: '批注与建议详情:'}
</div>
<div style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
{card.proposedContent}
</div>
</div>
)}
{/* 合规影响与风险提示 */}
{riskBadge.level !== 'none' && (
<div
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '6px',
fontSize: '11px',
fontWeight: 500,
padding: '4px 8px',
borderRadius: 'var(--r-sm)',
color: riskBadge.color,
background: riskBadge.bg,
border: '1px solid ' + riskBadge.color,
alignSelf: 'flex-start',
}}
>
<ShieldAlert size={12} />
<span>合规风险提示:{riskBadge.label}</span>
</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={{
padding: '8px 10px',
borderRadius: 'var(--r-sm)',
background: 'var(--card-resolved-bg, var(--success-bg))',
border: '1px solid var(--card-resolved-border, var(--success))',
color: 'var(--card-resolved-text, var(--success))',
fontSize: '11.5px',
display: 'flex',
flexDirection: 'column',
gap: '3px',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', fontWeight: 600 }}>
<CheckCircle2 size={13} />
<span>{resolution.summary}</span>
</div>
{(resolution.operator || resolution.time) && (
<div
style={{
fontSize: '10.5px',
color: 'var(--text-muted)',
display: 'flex',
gap: '10px',
paddingLeft: '19px',
}}
>
{resolution.operator && <span>操作人: {resolution.operator}</span>}
{resolution.time && <span>生效时间: {resolution.time}</span>}
</div>
)}
{/* 60 秒合法窗口期撤销机制 (Grace Period Undo) */}
{remainingSeconds > 0 && card.cardType !== 'PLAN_CONFIRM' && (
<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>
)}
{statusInfo.isOutdated && (
<div
style={{
padding: '7px 10px',
borderRadius: 'var(--r-sm)',
background: 'var(--card-outdated-bg, var(--amber-50))',
border: '1px solid var(--card-outdated-border, var(--amber-500))',
color: 'var(--card-outdated-text, var(--amber-600))',
fontSize: '11.5px',
display: 'flex',
alignItems: 'center',
gap: '6px',
fontWeight: 500,
}}
>
<AlertTriangle size={13} />
<span>⚠️ 文档状态已变更,操作失效</span>
</div>
)}
{statusInfo.isExpired && (
<div
style={{
padding: '7px 10px',
borderRadius: 'var(--r-sm)',
background: 'var(--card-expired-bg, var(--bg-subtle))',
border: '1px dashed var(--card-expired-border, var(--border-strong))',
color: 'var(--text-faint)',
fontSize: '11.5px',
display: 'flex',
alignItems: 'center',
gap: '6px',
}}
>
<Clock size={13} />
<span>⏱️ 已超时失效</span>
</div>
)}
{statusInfo.isCancelled && (
<div
style={{
padding: '7px 10px',
borderRadius: 'var(--r-sm)',
background: 'var(--bg-subtle)',
border: '1px solid var(--border)',
color: 'var(--text-muted)',
fontSize: '11.5px',
display: 'flex',
alignItems: 'center',
gap: '6px',
}}
>
<Ban size={13} />
<span>🚫 已放弃</span>
</div>
)}
{/* 操作按钮区 (仅在 active 或 executing 状态展示) */}
{(statusInfo.isActive || statusInfo.isExecuting) && card.actions && card.actions.length > 0 && (
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-end',
gap: '8px',
marginTop: '4px',
paddingTop: '6px',
borderTop: '1px solid var(--border-light)',
}}
>
{card.actions.map((act) => {
const isThisActionExecuting =
statusInfo.isExecuting && executingActionId === act.actionId;
// 按钮样式
let btnBg = 'var(--bg-panel)';
let btnColor = 'var(--text)';
let btnBorder = '1px solid var(--border)';
let btnShadow = 'none';
if (act.style === 'primary') {
btnBg = 'var(--brand-500)';
btnColor = '#ffffff';
btnBorder = 'none';
btnShadow = '0 2px 6px rgba(79, 107, 255, 0.3)';
} else if (act.style === 'danger') {
btnBg = 'var(--danger)';
btnColor = '#ffffff';
btnBorder = 'none';
btnShadow = '0 2px 6px rgba(228, 87, 75, 0.25)';
} else if (act.style === 'subtle') {
btnBg = 'var(--bg-subtle)';
btnColor = 'var(--text-muted)';
btnBorder = '1px solid var(--border)';
}
return (
<button
key={act.actionId}
disabled={!canClick || statusInfo.isExecuting}
onClick={() => handleActionClick(act)}
style={{
padding: card.cardType === 'PLAN_CONFIRM' ? '10px 14px' : '5px 12px',
minHeight: card.cardType === 'PLAN_CONFIRM' ? 44 : undefined,
borderRadius: 'var(--r-sm)',
fontSize: '11.5px',
fontWeight: act.isAffirmativeDefault ? 700 : 500,
background: btnBg,
color: btnColor,
border: btnBorder,
boxShadow: btnShadow,
display: 'flex',
alignItems: 'center',
gap: '5px',
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
? act.style === 'danger'
? '1.5px solid var(--danger)'
: '1.5px solid var(--brand-500)'
: 'none',
outlineOffset: act.isAffirmativeDefault ? '1px' : '0',
}}
>
{isThisActionExecuting ? (
<>
<Loader2 size={12} className="animate-spin-fast" />
<span>执行中...</span>
</>
) : (
<>
{act.isAffirmativeDefault && <Check size={12} />}
<span>{act.label}</span>
</>
)}
</button>
);
})}
</div>
)}
</div>
);
};