feat(web): build interactive card component with lifecycle and idempotency

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
WeiCHEN
2026-09-08 18:33:36 +08:00
co-authored by Cursor
parent 6e4fe22bfb
commit 1c62be6e49
6 changed files with 1093 additions and 3 deletions
+270 -1
View File
@@ -24,7 +24,8 @@
"@vitejs/plugin-react": "^5.1.0",
"tsx": "^4.23.13",
"typescript": "~5.9.3",
"vite": "^7.1.14"
"vite": "^7.1.14",
"vitest": "^5.0.0"
}
},
"node_modules/@antfu/install-pkg": {
@@ -1313,6 +1314,17 @@
"@babel/types": "^7.28.2"
}
},
"node_modules/@types/chai": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
"integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/deep-eql": "*",
"assertion-error": "^2.0.1"
}
},
"node_modules/@types/d3": {
"version": "7.4.3",
"resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz",
@@ -1566,6 +1578,13 @@
"@types/d3-selection": "*"
}
},
"node_modules/@types/deep-eql": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
@@ -1654,6 +1673,44 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
"node_modules/@vitest/mocker": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.0.tgz",
"integrity": "sha512-66PGTMIiVJP3t4a5yxU9qPtf7MdTBs8jmToMvy+HVflB3Yy13WJZTtPePdvU+wjRV02SKK5doLbSA6o9pwOmiA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/trace-mapping": "0.3.31",
"@vitest/spy": "5.0.0",
"estree-walker": "^3.0.3",
"magic-string": "^1.2.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"msw": "^2.4.9",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"msw": {
"optional": true
},
"vite": {
"optional": true
}
}
},
"node_modules/@vitest/spy": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.0.tgz",
"integrity": "sha512-uy+luWBAPw9XfthoHi5AkfHUnuPYEESjl0p/r+meoBnU8bxg5GDQ3Ey8MjcJ6sqahkL4PFyrvfMJJBw7LbU06g==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/adler-32": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
@@ -1663,6 +1720,16 @@
"node": ">=0.8"
}
},
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/baseline-browser-mapping": {
"version": "2.11.21",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.21.tgz",
@@ -1744,6 +1811,16 @@
"node": ">=0.8"
}
},
"node_modules/chai": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
"integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/codepage": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
@@ -2410,6 +2487,13 @@
"dev": true,
"license": "ISC"
},
"node_modules/es-module-lexer": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz",
"integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==",
"dev": true,
"license": "MIT"
},
"node_modules/es-toolkit": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.52.0.tgz",
@@ -2474,6 +2558,26 @@
"node": ">=6"
}
},
"node_modules/estree-walker": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0"
}
},
"node_modules/expect-type": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
"integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/fastdom": {
"version": "1.0.12",
"resolved": "https://registry.npmjs.org/fastdom/-/fastdom-1.0.12.tgz",
@@ -2706,6 +2810,16 @@
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/magic-string": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.2.3.tgz",
"integrity": "sha512-Bpb0W2TbLKOZ7vJnOUnVRGq3WL2p+ISV29M6hYPL1AFCpyKZpdr5ytiXoTSSxRVhg8YW7f65+6gbG8WG6PCa/g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/marked": {
"version": "16.4.2",
"resolved": "https://registry.npmjs.org/marked/-/marked-16.4.2.tgz",
@@ -2815,6 +2929,20 @@
"node": ">=18"
}
},
"node_modules/obug": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz",
"integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==",
"dev": true,
"funding": [
"https://github.com/sponsors/sxzz",
"https://opencollective.com/debug"
],
"license": "MIT",
"engines": {
"node": ">=12.20.0"
}
},
"node_modules/package-manager-detector": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.8.0.tgz",
@@ -3063,6 +3191,13 @@
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
"license": "MIT"
},
"node_modules/siginfo": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
"dev": true,
"license": "ISC"
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -3085,6 +3220,20 @@
"node": ">=0.8"
}
},
"node_modules/stackback": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
"dev": true,
"license": "MIT"
},
"node_modules/std-env": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz",
"integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==",
"dev": true,
"license": "MIT"
},
"node_modules/strictdom": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/strictdom/-/strictdom-1.0.1.tgz",
@@ -3106,6 +3255,16 @@
"integrity": "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==",
"license": "MIT"
},
"node_modules/tinybench": {
"version": "6.1.4",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.1.4.tgz",
"integrity": "sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/tinyexec": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz",
@@ -3306,6 +3465,116 @@
}
}
},
"node_modules/vitest": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.0.tgz",
"integrity": "sha512-gpsMNoRhMjMktVxPtstOH4/PJuPyovVaMDr4oDilXaGH1EcqM2OE96SoHT2VIQ6fTGtTjqmHDrEu2X9RQiXf8Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/chai": "^5.2.2",
"@vitest/mocker": "5.0.0",
"chai": "^6.2.2",
"es-module-lexer": "^2.3.2",
"expect-type": "^1.4.0",
"magic-string": "^1.2.3",
"obug": "^2.1.4",
"picomatch": "^4.0.7",
"std-env": "^4.2.0",
"tinybench": "6.1.4",
"tinyexec": "1.3.0",
"tinyglobby": "^0.2.17",
"why-is-node-running": "^2.3.0"
},
"bin": {
"vitest": "vitest.mjs"
},
"engines": {
"node": "^22.12.0 || ^24.0.0 || >=26.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@edge-runtime/vm": "*",
"@opentelemetry/api": "^1.9.0",
"@types/node": "^22.0.0 || >=24.0.0",
"@vitest/browser-playwright": "5.0.0",
"@vitest/browser-preview": "5.0.0",
"@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0",
"@vitest/coverage-istanbul": "5.0.0",
"@vitest/coverage-v8": "5.0.0",
"@vitest/ui": "5.0.0",
"happy-dom": "*",
"jsdom": "*",
"vite": "^6.4.0 || ^7.0.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"@edge-runtime/vm": {
"optional": true
},
"@opentelemetry/api": {
"optional": true
},
"@types/node": {
"optional": true
},
"@vitest/browser-playwright": {
"optional": true
},
"@vitest/browser-preview": {
"optional": true
},
"@vitest/browser-webdriverio": {
"optional": true
},
"@vitest/coverage-istanbul": {
"optional": true
},
"@vitest/coverage-v8": {
"optional": true
},
"@vitest/ui": {
"optional": true
},
"happy-dom": {
"optional": true
},
"jsdom": {
"optional": true
},
"vite": {
"optional": false
}
}
},
"node_modules/vitest/node_modules/tinyexec": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz",
"integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/why-is-node-running": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
"dev": true,
"license": "MIT",
"dependencies": {
"siginfo": "^2.0.0",
"stackback": "0.0.2"
},
"bin": {
"why-is-node-running": "cli.js"
},
"engines": {
"node": ">=8"
}
},
"node_modules/wmf": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
+3 -2
View File
@@ -7,7 +7,7 @@
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"test": "tsx --test \"src/**/*.test.ts\""
"test": "tsx --test \"src/**/!(*InteractiveCard*).test.ts\" && vitest run src/components/chat/InteractiveCard.test.ts"
},
"dependencies": {
"docx": "^9.7.1",
@@ -26,6 +26,7 @@
"@vitejs/plugin-react": "^5.1.0",
"tsx": "^4.23.13",
"typescript": "~5.9.3",
"vite": "^7.1.14"
"vite": "^7.1.14",
"vitest": "^5.0.0"
}
}
@@ -0,0 +1,166 @@
import { describe, it, expect } from 'vitest';
import { InteractiveCardData, CardActionItem } from '../../types/workbench';
import {
getCardTypeMeta,
formatResolutionSummary,
formatStatusInfo,
isCardActionable,
resolveRiskBadge,
computeCardState,
} from './InteractiveCard';
describe('InteractiveCard Logic', () => {
it('should format resolution text correctly', () => {
const card: InteractiveCardData = {
cardId: 'c-1',
cardType: 'CONFIRMATION',
title: '审核驳回流转确认',
status: 'resolved',
ttlTurns: 3,
actions: [],
resolutionSummary: '已正式退回编制阶段,批注已挂载',
resolvedBy: '李主管',
resolvedAt: '16:10',
};
expect(card.status).toBe('resolved');
expect(card.resolutionSummary).toContain('已正式退回编制阶段');
const formatted = formatResolutionSummary(card);
expect(formatted.summary).toBe('已正式退回编制阶段,批注已挂载');
expect(formatted.operator).toBe('李主管');
expect(formatted.time).toBe('16:10');
});
describe('Card Type Visual Metas', () => {
it('should provide proper metadata for all 4 card types', () => {
const confMeta = getCardTypeMeta('CONFIRMATION');
expect(confMeta.label).toBe('流转确认');
expect(confMeta.badgeText).toContain('确认');
const slotMeta = getCardTypeMeta('SLOT_FILLING');
expect(slotMeta.label).toBe('要素补全');
expect(slotMeta.badgeText).toContain('补全');
const diffMeta = getCardTypeMeta('DIFF_PROPOSAL');
expect(diffMeta.label).toBe('变更提案');
expect(diffMeta.badgeText).toContain('提案');
const reminderMeta = getCardTypeMeta('REMINDER_NOTICE');
expect(reminderMeta.label).toBe('合规提醒');
expect(reminderMeta.badgeText).toContain('提醒');
});
});
describe('Status States and Banners', () => {
it('should identify active actionable state', () => {
expect(isCardActionable('active')).toBe(true);
expect(isCardActionable('executing')).toBe(false);
expect(isCardActionable('resolved')).toBe(false);
expect(isCardActionable('cancelled')).toBe(false);
expect(isCardActionable('outdated')).toBe(false);
expect(isCardActionable('expired')).toBe(false);
});
it('should format resolved status banner', () => {
const info = formatStatusInfo('resolved');
expect(info.badgeText).toBe('✅ 已生效');
expect(info.isResolved).toBe(true);
});
it('should format outdated status banner', () => {
const info = formatStatusInfo('outdated');
expect(info.badgeText).toBe('⚠️ 文档状态已变更,操作失效');
expect(info.isOutdated).toBe(true);
});
it('should format expired status banner', () => {
const info = formatStatusInfo('expired');
expect(info.badgeText).toBe('⏱️ 已超时失效');
expect(info.isExpired).toBe(true);
});
it('should format cancelled status banner', () => {
const info = formatStatusInfo('cancelled');
expect(info.badgeText).toBe('🚫 已放弃');
expect(info.isCancelled).toBe(true);
});
it('should format executing status banner', () => {
const info = formatStatusInfo('executing');
expect(info.badgeText).toContain('执行中');
expect(info.isExecuting).toBe(true);
});
});
describe('Action payload and execution idempotency', () => {
it('should correctly match payload and styles for action items', () => {
const actions: CardActionItem[] = [
{
actionId: 'confirm_reject',
label: '确认驳回',
style: 'danger',
payload: {
docId: 'doc-002',
reason: '缺少指标',
},
isAffirmativeDefault: true,
},
{
actionId: 'cancel',
label: '放弃操作',
style: 'subtle',
},
];
expect(actions[0].style).toBe('danger');
expect(actions[0].isAffirmativeDefault).toBe(true);
expect(actions[0].payload?.docId).toBe('doc-002');
expect(actions[0].payload?.reason).toBe('缺少指标');
expect(actions[1].style).toBe('subtle');
expect(actions[1].isAffirmativeDefault).toBeFalsy();
});
it('should maintain idempotency during state transitions', () => {
// 模拟初始 active 状态
const state1 = computeCardState('active', null);
expect(state1.effectiveStatus).toBe('active');
expect(state1.canClick).toBe(true);
// 用户点击后进入 executing
const state2 = computeCardState('active', 'executing');
expect(state2.effectiveStatus).toBe('executing');
expect(state2.canClick).toBe(false);
// 后端推送 resolved 状态
const state3 = computeCardState('resolved', null);
expect(state3.effectiveStatus).toBe('resolved');
expect(state3.canClick).toBe(false);
// 后端推送 outdated 状态
const state4 = computeCardState('outdated', null);
expect(state4.effectiveStatus).toBe('outdated');
expect(state4.canClick).toBe(false);
});
});
describe('Risk Badge and Anchoring', () => {
it('should resolve risk badges correctly', () => {
const high = resolveRiskBadge('高风险:可能导致重测');
expect(high.level).toBe('high');
expect(high.color).toContain('danger');
const medium = resolveRiskBadge('中风险');
expect(medium.level).toBe('medium');
expect(medium.color).toContain('amber');
const low = resolveRiskBadge('低风险');
expect(low.level).toBe('low');
expect(low.color).toContain('success');
const none = resolveRiskBadge(undefined);
expect(none.level).toBe('none');
});
});
});
+627
View File
@@ -0,0 +1,627 @@
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,
} 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 '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)',
};
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(
cardStatus: CardActionStatus,
localStatus: CardActionStatus | null
): { effectiveStatus: CardActionStatus; canClick: boolean } {
const effectiveStatus = cardStatus === 'active' && localStatus ? localStatus : cardStatus;
const canClick = effectiveStatus === 'active';
return { effectiveStatus, canClick };
}
/**
* 解析合规风险徽章
*/
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);
// 同步外部 card.status 变化,重置本地临时执行态
useEffect(() => {
if (card.status !== 'active') {
setLocalStatus(null);
setExecutingActionId(null);
}
}, [card.status]);
const { effectiveStatus, canClick } = computeCardState(card.status, localStatus);
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);
};
return (
<div
className={className}
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.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' ? '拟变更草案内容:' : '批注与建议详情:'}
</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}33`,
alignSelf: 'flex-start',
}}
>
<ShieldAlert size={12} />
<span>合规风险提示:{riskBadge.label}</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>
)}
</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: '5px 12px',
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 ? '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>
);
};
+22
View File
@@ -52,6 +52,17 @@
--r-lg: 14px;
--r-pill: 999px;
/* 交互卡片生命周期与印章 Tokens */
--card-resolved-border: var(--success);
--card-resolved-bg: var(--success-bg);
--card-resolved-text: var(--success);
--card-outdated-border: var(--amber-500);
--card-outdated-bg: var(--amber-50);
--card-outdated-text: var(--amber-600);
--card-expired-border: var(--border-strong);
--card-expired-bg: var(--bg-subtle);
--card-stamp-shadow: 0 2px 8px rgba(33, 166, 106, 0.2);
/* 语义化组件色彩兼容别名 (防透明穿透兜底) */
--color-bg-surface: var(--bg-panel);
--color-bg-canvas: var(--bg-subtle);
@@ -104,6 +115,17 @@
--shadow-md: 0 8px 24px rgba(0, 0, 0, 0.5), 0 2px 6px rgba(0, 0, 0, 0.35);
--shadow-lg: 0 18px 48px rgba(0, 0, 0, 0.6), 0 4px 12px rgba(0, 0, 0, 0.4);
/* 交互卡片生命周期与印章 Tokens */
--card-resolved-border: var(--success);
--card-resolved-bg: var(--success-bg);
--card-resolved-text: var(--success);
--card-outdated-border: var(--amber-500);
--card-outdated-bg: var(--amber-50);
--card-outdated-text: var(--amber-500);
--card-expired-border: var(--border-strong);
--card-expired-bg: var(--bg-subtle);
--card-stamp-shadow: 0 2px 10px rgba(53, 199, 126, 0.25);
/* 语义化组件色彩兼容别名 (防透明穿透兜底) */
--color-bg-surface: var(--bg-panel);
--color-bg-canvas: var(--bg-subtle);
+5
View File
@@ -3,6 +3,10 @@ import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
// @ts-expect-error vitest config field
test: {
include: ['src/components/chat/**/*.test.ts'],
},
server: {
port: 3000,
proxy: {
@@ -17,3 +21,4 @@ export default defineConfig({
emptyOutDir: true,
},
});