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>
863 lines
26 KiB
TypeScript
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>
|
|
);
|
|
};
|