Files
Agent-System/web/src/components/user/UserProjectRightsModal.tsx
T
weichenandCursor 3a0f7a1033 fix(web): route prefixed task keys and keep live settings editable
Message routing follows the project's real task key, account switches clear local workbench state, and the duty matrix writes the reassignment API instead of a local drill.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-21 05:20:01 +00:00

610 lines
23 KiB
TypeScript

import { FC, useEffect, useState } 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 | Promise<void>;
}
export const UserProjectRightsModal: FC<UserProjectRightsModalProps> = ({
isOpen,
onClose,
currentUser,
currentProjectTitle,
deliverables,
activeDocId,
canAssignLead = true,
onSelectDoc,
onRoleChangeForDoc,
}) => {
const [dutyError, setDutyError] = useState('');
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>
{currentUser.employeeId ? (
<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}
</span>
) : null}
</div>
{(currentUser.department || currentUser.position) && (
<div
style={{
fontSize: '13px',
color: 'var(--text)',
marginTop: '4px',
display: 'flex',
alignItems: 'center',
gap: '6px',
}}
>
<Briefcase size={14} color="var(--text-muted)" />
{currentUser.department ? <span>{currentUser.department}</span> : null}
{currentUser.department && currentUser.position ? (
<span style={{ color: 'var(--border)' }}>·</span>
) : null}
{currentUser.position ? (
<span style={{ fontWeight: 500, color: 'var(--text-strong)' }}>{currentUser.position}</span>
) : null}
</div>
)}
</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>
{dutyError ? (
<div role="alert" style={{ fontSize: '12px', color: 'var(--danger, #b91c1c)', marginBottom: '8px' }}>
{dutyError}
</div>
) : null}
{/* 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 && canAssignLead && item.projectId && item.taskKey ? (
<select
data-testid={DOC_DUTY_SELECT_TESTID}
data-doc-id={item.docId}
data-doc-role={role}
data-can-assign="true"
value={role}
onChange={(e) => {
const next = e.target.value as DocPermissionRole;
if (next === 'viewer') return;
setDutyError('');
void Promise.resolve(onRoleChangeForDoc(item.docId, next)).catch((err: unknown) => {
setDutyError(err instanceof Error ? err.message : '改派文档职责失败');
});
}}
style={{
padding: '4px 8px',
borderRadius: '4px',
border: '1px solid var(--border)',
backgroundColor: badge.bg,
color: badge.color,
fontWeight: 500,
fontSize: '11px',
cursor: 'pointer',
}}
title="写入工作流职责:把当前账号设为该文档的编制、审核或批准人"
>
<option value="author">设为编制负责人</option>
<option value="reviewer">设为审核负责人</option>
<option value="approver">设为批准负责人</option>
{role === 'viewer' ? <option value="viewer">查阅者(不改派)</option> : null}
</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>
);
};