feat(web): build interactive card component with lifecycle and idempotency
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Generated
+270
-1
@@ -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
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user