Files
Agent-System/web/src/components/user/UserProjectRightsModal.tsx
T
weichenandCursor c8318e8551 fix(e2e): read duty from select value, not row option text
UI-13a treated concatenated select options as the current role.
Assert the control value or badge role for that doc instead.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-13 21:26:23 +00:00

614 lines
22 KiB
TypeScript

import { FC, useEffect } from 'react';
import {
X,
ShieldCheck,
FileText,
Briefcase,
ExternalLink,
Info,
} from 'lucide-react';
import { UserProfile, DocPermissionRole } from '../../types/docgov';
import { DeliverableItem } from '../../types/workbench';
import { resolveCurrentDocRole } from '../../lib/currentDocRole';
import {
DOC_DUTY_BADGE_TESTID,
DOC_DUTY_ROW_TESTID,
DOC_DUTY_SELECT_TESTID,
} from '../../lib/dutyCellAssertion';
interface UserProjectRightsModalProps {
isOpen: boolean;
onClose: () => void;
currentUser: UserProfile;
currentProjectTitle: string;
deliverables: DeliverableItem[];
activeDocId: string;
canAssignLead?: boolean;
onSelectDoc: (docId: string) => void;
onRoleChangeForDoc?: (docId: string, role: DocPermissionRole) => void;
}
export const UserProjectRightsModal: FC<UserProjectRightsModalProps> = ({
isOpen,
onClose,
currentUser,
currentProjectTitle,
deliverables,
activeDocId,
canAssignLead = true,
onSelectDoc,
onRoleChangeForDoc,
}) => {
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isOpen) {
onClose();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [isOpen, onClose]);
if (!isOpen) return null;
// Compute stats
const permissions = currentUser.projectPermissions || {};
let authorCount = 0;
let reviewerCount = 0;
let approverCount = 0;
let viewerCount = 0;
const roleForItem = (item: DeliverableItem): DocPermissionRole =>
resolveCurrentDocRole({
currentUser,
authorId: item.authorId,
reviewerId: item.reviewerId,
approverId: item.approverId,
author: item.author,
reviewer: item.reviewer,
approver: item.approver,
projectPermission: permissions[item.docId],
dutiesAuthoritative: item.dutiesAuthoritative,
governanceMode: item.governanceMode,
isLegacyLocalDoc:
item.dutiesAuthoritative !== true &&
item.governanceMode !== 'controlled' &&
/^doc-p\d+-/.test(item.docId),
});
deliverables.forEach((item) => {
const role = roleForItem(item);
if (role === 'author') authorCount++;
else if (role === 'reviewer') reviewerCount++;
else if (role === 'approver') approverCount++;
else viewerCount++;
});
const getRoleBadge = (role: DocPermissionRole) => {
switch (role) {
case 'author':
return {
label: '✍️ 编制负责人',
color: '#3B82F6',
bg: 'rgba(59, 130, 246, 0.15)',
desc: '拥有正文就地编辑、向智能体发起修改、认领与送审权限',
};
case 'reviewer':
return {
label: '🔍 审核负责人',
color: '#F59E0B',
bg: 'rgba(245, 158, 11, 0.15)',
desc: '文档锁定只读,拥有行内批注、评审通过或驳回权限',
};
case 'approver':
return {
label: '👑 批准负责人',
color: '#A855F7',
bg: 'rgba(168, 85, 247, 0.15)',
desc: '终审决策人,拥有最终批准盖章或否决驳回权限',
};
default:
return {
label: '👁️ 查阅者 (只读)',
color: '#94A3B8',
bg: 'rgba(148, 163, 184, 0.15)',
desc: '无该文档编制或评审职责,拥有全局受控版本查阅权',
};
}
};
return (
<div
style={{
position: 'fixed',
inset: 0,
backgroundColor: 'rgba(11, 14, 20, 0.75)',
backdropFilter: 'blur(6px)',
zIndex: 1000,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '20px',
}}
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div
style={{
width: '740px',
maxWidth: '95vw',
maxHeight: '90vh',
backgroundColor: 'var(--bg-panel, #131820)',
borderRadius: '12px',
border: '1px solid var(--border)',
boxShadow: 'var(--shadow-lg)',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
animation: 'scaleIn 0.18s ease-out',
}}
>
{/* Header */}
<div
style={{
padding: '16px 20px',
borderBottom: '1px solid var(--border)',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
backgroundColor: 'var(--bg-subtle, #1A212C)',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
<div
style={{
width: '36px',
height: '36px',
borderRadius: '8px',
backgroundColor: 'rgba(79, 107, 255, 0.15)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'var(--brand-500)',
}}
>
<ShieldCheck size={20} />
</div>
<div>
<div style={{ fontSize: '15px', fontWeight: 600, color: 'var(--text-strong)' }}>
当前账号与项目职责权限矩阵
</div>
<div style={{ fontSize: '12px', color: 'var(--text-muted)', marginTop: '2px' }}>
用户身份与职位全局固定 · 在不同项目与交付物中履行独立法定职责
</div>
</div>
</div>
<button
onClick={onClose}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
color: 'var(--text-muted)',
padding: '6px',
borderRadius: '6px',
display: 'flex',
}}
>
<X size={18} />
</button>
</div>
{/* Content Body */}
<div
style={{
padding: '20px',
overflowY: 'auto',
flex: 1,
display: 'flex',
flexDirection: 'column',
gap: '20px',
backgroundColor: 'var(--bg-panel, #131820)',
}}
>
{/* User Profile Card */}
<div
style={{
padding: '14px 18px',
borderRadius: '8px',
border: '1px solid var(--border)',
backgroundColor: 'var(--bg-subtle, #1A212C)',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: '16px',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '14px' }}>
<div
style={{
width: '46px',
height: '46px',
borderRadius: '50%',
backgroundColor: 'var(--brand-500)',
color: '#fff',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontWeight: 600,
fontSize: '18px',
boxShadow: '0 2px 8px rgba(79, 107, 255, 0.3)',
}}
>
{currentUser.name.slice(0, 1)}
</div>
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span style={{ fontSize: '16px', fontWeight: 600, color: 'var(--text-strong)' }}>
{currentUser.name}
</span>
<span
style={{
fontSize: '11px',
color: 'var(--text-muted)',
backgroundColor: 'rgba(148, 163, 184, 0.1)',
padding: '2px 6px',
borderRadius: '4px',
fontFamily: 'var(--font-mono)',
}}
>
工号: {currentUser.employeeId || 'SMDF-0821'}
</span>
</div>
<div
style={{
fontSize: '13px',
color: 'var(--text)',
marginTop: '4px',
display: 'flex',
alignItems: 'center',
gap: '6px',
}}
>
<Briefcase size={14} color="var(--text-muted)" />
<span>{currentUser.department || '机器人与微创内窥镜研发中心'}</span>
<span style={{ color: 'var(--border)' }}>·</span>
<span style={{ fontWeight: 500, color: 'var(--text-strong)' }}>
{currentUser.position || '资深医疗器械方案工程师'}
</span>
</div>
</div>
</div>
{/* Global Position Pill */}
<div
style={{
fontSize: '11px',
color: 'var(--success)',
backgroundColor: 'var(--success-bg)',
border: '1px solid rgba(53, 199, 126, 0.25)',
padding: '6px 12px',
borderRadius: '6px',
textAlign: 'right',
lineHeight: 1.4,
}}
>
<div>法定资质有效</div>
<div style={{ fontSize: '10px', color: 'var(--text-muted)' }}>ISO 13485 / GMP 认证</div>
</div>
</div>
{/* Active Project Banner & Stats */}
<div>
<div
style={{
fontSize: '13px',
fontWeight: 600,
color: 'var(--text-strong)',
marginBottom: '8px',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<span>当前激活项目:{currentProjectTitle}</span>
<span style={{ fontSize: '12px', fontWeight: 400, color: 'var(--text-muted)' }}>
交付物总计: {deliverables.length} 份
</span>
</div>
{/* Quick Stats Badges */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '10px' }}>
<div
style={{
padding: '10px',
borderRadius: '6px',
backgroundColor: 'rgba(59, 130, 246, 0.1)',
border: '1px solid rgba(59, 130, 246, 0.25)',
display: 'flex',
flexDirection: 'column',
gap: '2px',
}}
>
<span style={{ fontSize: '11px', color: '#60A5FA' }}>待我编制</span>
<span style={{ fontSize: '18px', fontWeight: 700, color: '#60A5FA' }}>{authorCount} 份</span>
</div>
<div
style={{
padding: '10px',
borderRadius: '6px',
backgroundColor: 'rgba(245, 158, 11, 0.1)',
border: '1px solid rgba(245, 158, 11, 0.25)',
display: 'flex',
flexDirection: 'column',
gap: '2px',
}}
>
<span style={{ fontSize: '11px', color: '#FBBF24' }}>待我审核</span>
<span style={{ fontSize: '18px', fontWeight: 700, color: '#FBBF24' }}>{reviewerCount} 份</span>
</div>
<div
style={{
padding: '10px',
borderRadius: '6px',
backgroundColor: 'rgba(168, 85, 247, 0.1)',
border: '1px solid rgba(168, 85, 247, 0.25)',
display: 'flex',
flexDirection: 'column',
gap: '2px',
}}
>
<span style={{ fontSize: '11px', color: '#C084FC' }}>待我批准</span>
<span style={{ fontSize: '18px', fontWeight: 700, color: '#C084FC' }}>{approverCount} 份</span>
</div>
<div
style={{
padding: '10px',
borderRadius: '6px',
backgroundColor: 'rgba(148, 163, 184, 0.1)',
border: '1px solid rgba(148, 163, 184, 0.25)',
display: 'flex',
flexDirection: 'column',
gap: '2px',
}}
>
<span style={{ fontSize: '11px', color: '#94A3B8' }}>查阅权限</span>
<span style={{ fontSize: '18px', fontWeight: 700, color: '#94A3B8' }}>{viewerCount} 份</span>
</div>
</div>
</div>
{/* Document Responsibilities Table */}
<div>
<div
style={{
fontSize: '13px',
fontWeight: 600,
color: 'var(--text-strong)',
marginBottom: '10px',
display: 'flex',
alignItems: 'center',
gap: '6px',
}}
>
<FileText size={15} color="var(--brand-500)" />
<span>本项目的交付物分工与法定操作权限</span>
</div>
<div
style={{
borderRadius: '8px',
border: '1px solid var(--border)',
overflow: 'hidden',
backgroundColor: 'var(--bg-card, #151B25)',
}}
>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '12px' }}>
<thead>
<tr
style={{
backgroundColor: 'var(--bg-subtle, #1A212C)',
borderBottom: '1px solid var(--border)',
textAlign: 'left',
color: 'var(--text-muted)',
}}
>
<th style={{ padding: '8px 12px', width: '90px' }}>阶段</th>
<th style={{ padding: '8px 12px' }}>受控交付物</th>
<th style={{ padding: '8px 12px', width: '180px' }}>您在此文档的法定职责</th>
<th style={{ padding: '8px 12px', width: '90px', textAlign: 'center' }}>快捷操作</th>
</tr>
</thead>
<tbody>
{deliverables.map((item, idx) => {
const role = roleForItem(item);
const badge = getRoleBadge(role);
const isCurrentActive = item.docId === activeDocId;
return (
<tr
key={item.docId}
data-testid={DOC_DUTY_ROW_TESTID}
data-doc-id={item.docId}
data-doc-role={role}
style={{
borderBottom: idx === deliverables.length - 1 ? 'none' : '1px solid var(--border)',
backgroundColor: isCurrentActive ? 'rgba(79, 107, 255, 0.08)' : 'transparent',
}}
>
<td style={{ padding: '10px 12px', color: 'var(--text)' }}>
<span
style={{
padding: '2px 6px',
borderRadius: '4px',
backgroundColor: 'rgba(148, 163, 184, 0.1)',
fontSize: '11px',
fontWeight: 500,
}}
>
{item.stage}
</span>
</td>
<td style={{ padding: '10px 12px' }}>
<div style={{ fontWeight: 500, color: 'var(--text-strong)' }}>
{item.title}
{isCurrentActive && (
<span
style={{
marginLeft: '6px',
fontSize: '10px',
color: 'var(--brand-500)',
backgroundColor: 'rgba(79, 107, 255, 0.15)',
padding: '1px 5px',
borderRadius: '3px',
}}
>
当前打开
</span>
)}
</div>
<div style={{ fontSize: '11px', color: 'var(--text-muted)', marginTop: '2px' }}>
状态: {item.lifecycleState}
</div>
</td>
<td style={{ padding: '10px 12px' }}>
{onRoleChangeForDoc ? (
<select
data-testid={DOC_DUTY_SELECT_TESTID}
data-doc-id={item.docId}
data-doc-role={role}
data-can-assign={canAssignLead ? 'true' : 'false'}
value={role}
disabled={!canAssignLead}
onChange={(e) => onRoleChangeForDoc(item.docId, e.target.value as DocPermissionRole)}
style={{
padding: '4px 8px',
borderRadius: '4px',
border: '1px solid var(--border)',
backgroundColor: badge.bg,
color: badge.color,
fontWeight: 500,
fontSize: '11px',
cursor: canAssignLead ? 'pointer' : 'not-allowed',
opacity: canAssignLead ? 1 : 0.65,
}}
title={
canAssignLead
? '演练测试:支持切换该文档下的负责角色'
: '当前角色无权配置文档法定职责'
}
>
<option value="author">✍️ 编制负责人</option>
<option value="reviewer">🔍 审核负责人</option>
<option value="approver">👑 批准负责人</option>
<option value="viewer">👁️ 查阅者 (只读)</option>
</select>
) : (
<span
data-testid={DOC_DUTY_BADGE_TESTID}
data-doc-id={item.docId}
data-doc-role={role}
style={{
display: 'inline-block',
padding: '3px 8px',
borderRadius: '4px',
backgroundColor: badge.bg,
color: badge.color,
fontWeight: 500,
fontSize: '11px',
}}
title={badge.desc}
>
{badge.label}
</span>
)}
</td>
<td style={{ padding: '10px 12px', textAlign: 'center' }}>
<button
onClick={() => {
onSelectDoc(item.docId);
onClose();
}}
style={{
padding: '4px 8px',
borderRadius: '4px',
border: '1px solid var(--border)',
backgroundColor: 'var(--bg-subtle, #1A212C)',
color: 'var(--text)',
cursor: 'pointer',
fontSize: '11px',
display: 'inline-flex',
alignItems: 'center',
gap: '4px',
}}
>
<ExternalLink size={12} />
<span>前往</span>
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
{/* Explanatory Tip */}
<div
style={{
padding: '10px 14px',
borderRadius: '6px',
backgroundColor: 'rgba(79, 107, 255, 0.08)',
border: '1px solid rgba(79, 107, 255, 0.2)',
display: 'flex',
alignItems: 'flex-start',
gap: '8px',
fontSize: '11px',
color: 'var(--text)',
lineHeight: 1.5,
}}
>
<Info size={14} color="var(--brand-500)" style={{ flexShrink: 0, marginTop: '2px' }} />
<div>
<strong>权限与操作联动说明:</strong>
当您切换查看某份文档时,右侧工作栏将实时根据您在该文档上的法定职责赋予对应权限——如在《注册评估》中作为编制人可就地编辑与送审;在《竞品调研》中作为审核人则只读锁定并开放批注与通过/驳回动作。
</div>
</div>
</div>
{/* Footer */}
<div
style={{
padding: '12px 20px',
borderTop: '1px solid var(--border)',
backgroundColor: 'var(--bg-subtle, #1A212C)',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<div />
<button
onClick={onClose}
style={{
padding: '6px 16px',
borderRadius: '6px',
backgroundColor: 'var(--brand-500)',
border: 'none',
color: '#fff',
fontSize: '12px',
fontWeight: 500,
cursor: 'pointer',
}}
>
关闭面板
</button>
</div>
</div>
</div>
);
};