From 1c62be6e495b8a276331be06a9058342bccfd0fb Mon Sep 17 00:00:00 2001 From: WeiCHEN Date: Tue, 8 Sep 2026 18:33:36 +0800 Subject: [PATCH] feat(web): build interactive card component with lifecycle and idempotency Co-authored-by: Cursor --- web/package-lock.json | 271 +++++++- web/package.json | 5 +- .../components/chat/InteractiveCard.test.ts | 166 +++++ web/src/components/chat/InteractiveCard.tsx | 627 ++++++++++++++++++ web/src/styles/tokens.css | 22 + web/vite.config.ts | 5 + 6 files changed, 1093 insertions(+), 3 deletions(-) create mode 100644 web/src/components/chat/InteractiveCard.test.ts create mode 100644 web/src/components/chat/InteractiveCard.tsx diff --git a/web/package-lock.json b/web/package-lock.json index b420a6df..2c541375 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -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", diff --git a/web/package.json b/web/package.json index 44f1e41a..e8908566 100644 --- a/web/package.json +++ b/web/package.json @@ -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" } } diff --git a/web/src/components/chat/InteractiveCard.test.ts b/web/src/components/chat/InteractiveCard.test.ts new file mode 100644 index 00000000..62223786 --- /dev/null +++ b/web/src/components/chat/InteractiveCard.test.ts @@ -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'); + }); + }); +}); diff --git a/web/src/components/chat/InteractiveCard.tsx b/web/src/components/chat/InteractiveCard.tsx new file mode 100644 index 00000000..10cd263f --- /dev/null +++ b/web/src/components/chat/InteractiveCard.tsx @@ -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 | 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 = ({ + card, + onExecuteAction, + className, + style, +}) => { + const [localStatus, setLocalStatus] = useState(null); + const [executingActionId, setExecutingActionId] = useState(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 ( +
+ {/* 头部:类型徽章、标题与 TTL 倒计时 / 已生效印章 */} +
+
+ + + {typeMeta.badgeText} + + + + {card.title} + +
+ + {/* 状态徽标 / TTL 轮次 / 印章 */} +
+ {statusInfo.isActive && card.ttlTurns !== undefined && ( + + + 剩余 {card.ttlTurns} 轮 + + )} + + {statusInfo.isResolved && ( +
+ + 已生效 +
+ )} +
+
+ + {/* 锚定目标文档与章节 */} + {(card.targetDocTitle || card.targetDocId || card.sectionTitle || card.targetSectionId) && ( +
+ {(card.targetDocTitle || card.targetDocId) && ( +
+ + 目标文档: + + {card.targetDocTitle || card.targetDocId} + +
+ )} + {(card.sectionTitle || card.targetSectionId) && ( +
+ + 锚定段落: + + {card.sectionTitle || card.targetSectionId} + +
+ )} +
+ )} + + {/* 拟建议修改内容 / 批注展示 */} + {card.proposedContent && ( +
+
+ {card.cardType === 'DIFF_PROPOSAL' ? '拟变更草案内容:' : '批注与建议详情:'} +
+
+ {card.proposedContent} +
+
+ )} + + {/* 合规影响与风险提示 */} + {riskBadge.level !== 'none' && ( +
+ + 合规风险提示:{riskBadge.label} +
+ )} + + {/* 状态横幅展示:已生效 / 已失效 / 已超时 / 已放弃 */} + {statusInfo.isResolved && ( +
+
+ + {resolution.summary} +
+ {(resolution.operator || resolution.time) && ( +
+ {resolution.operator && 操作人: {resolution.operator}} + {resolution.time && 生效时间: {resolution.time}} +
+ )} +
+ )} + + {statusInfo.isOutdated && ( +
+ + ⚠️ 文档状态已变更,操作失效 +
+ )} + + {statusInfo.isExpired && ( +
+ + ⏱️ 已超时失效 +
+ )} + + {statusInfo.isCancelled && ( +
+ + 🚫 已放弃 +
+ )} + + {/* 操作按钮区 (仅在 active 或 executing 状态展示) */} + {(statusInfo.isActive || statusInfo.isExecuting) && card.actions && card.actions.length > 0 && ( +
+ {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 ( + + ); + })} +
+ )} +
+ ); +}; diff --git a/web/src/styles/tokens.css b/web/src/styles/tokens.css index 91ad040f..08f0e63c 100644 --- a/web/src/styles/tokens.css +++ b/web/src/styles/tokens.css @@ -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); diff --git a/web/vite.config.ts b/web/vite.config.ts index 88518d97..9ff9f615 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -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, }, }); +