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>
610 lines
23 KiB
TypeScript
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>
|
|
);
|
|
};
|