feat(web): keep viewer comments read-only

Hide add-comment affordances and reject submit for viewer; keep
author/reviewer/approver comment roles unchanged.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
weichen
2026-09-13 17:08:35 +00:00
co-authored by Cursor
parent 4dbd9eecb9
commit d5ce81228c
5 changed files with 70 additions and 8 deletions
+8 -2
View File
@@ -50,6 +50,7 @@ import {
} from './lib/projectMemberAdapter';
import { mergeWorkflowDocumentsFromServer } from './lib/deliverableSync';
import { resolveCurrentDocRole } from './lib/currentDocRole';
import { canAddBlockComment } from './lib/commentPolicy';
import {
pollUntilPostTaskServerDoc,
shouldSwitchTemplateSlotToServer,
@@ -1233,7 +1234,12 @@ export default function App() {
reviewer: docAST.reviewer,
approver: docAST.approver,
projectPermission: currentUser.projectPermissions?.[activeDocId],
isLegacyLocalDoc: !docAST.authorId && !docAST.reviewerId && !docAST.approverId,
dutiesAuthoritative: docAST.dutiesAuthoritative,
governanceMode: docAST.governanceMode,
isLegacyLocalDoc:
docAST.dutiesAuthoritative !== true &&
docAST.governanceMode !== 'controlled' &&
/^doc-p\d+-/.test(docAST.docId),
});
const setDocAST = (newAst: DocumentAST) => {
@@ -3416,7 +3422,7 @@ export default function App() {
// 批注操作
const handleAddComment = (comment: Omit<BlockComment, 'id' | 'createdAt'>) => {
if (!commentingBlock) return;
if (!commentingBlock || !canAddBlockComment(currentDocRole)) return;
const newCmt: BlockComment = {
...comment,
id: 'cmt_' + Math.random().toString(36).substring(2, 8),
+4 -3
View File
@@ -11,6 +11,7 @@ import { MermaidBlockView } from './blocks/MermaidBlockView';
import { MathBlockView } from './blocks/MathBlockView';
import { ListBlockView } from './blocks/ListBlockView';
import { BlockContextMenu } from './BlockContextMenu';
import { canAddBlockComment } from '../../lib/commentPolicy';
import { Plus, ArrowUp, ArrowDown, Trash2, MessageSquare } from 'lucide-react';
export interface BlockRendererProps {
@@ -61,7 +62,7 @@ export const BlockRenderer: React.FC<BlockRendererProps> = ({
!!ast.editLease?.holderType &&
(!ast.editLease.expiresAt || new Date(ast.editLease.expiresAt).getTime() > Date.now());
const isEditable = currentRole === 'author' && ast.lifecycleState === 'drafting' && !leaseActive;
const canAnnotate = !leaseActive;
const canAnnotate = !leaseActive && canAddBlockComment(currentRole);
const children = ast.rootBlock.children || [];
const renderBlockContent = (block: DocumentBlock) => {
@@ -267,7 +268,7 @@ export const BlockRenderer: React.FC<BlockRendererProps> = ({
)}
{/* 批注按钮:编辑租约占用期间禁用 */}
{canAnnotate && (
{(canAnnotate || commentCount > 0) && (
<button
onClick={(e) => {
e.stopPropagation();
@@ -283,7 +284,7 @@ export const BlockRenderer: React.FC<BlockRendererProps> = ({
fontSize: '11px',
fontWeight: 600,
}}
title="查看或添加审核批注"
title={canAnnotate ? '查看或添加审核批注' : '查看审核批注'}
>
<MessageSquare size={12} />
{commentCount > 0 && <span>{commentCount}</span>}
+21 -3
View File
@@ -1,6 +1,7 @@
import React, { useState } from 'react';
import { DocumentBlock, BlockComment } from '../../types/block';
import { UserRole } from '../../types/docgov';
import { canAddBlockComment, resolveCommentAuthorRole } from '../../lib/commentPolicy';
import { X, Send, CheckCircle2, MessageSquare } from 'lucide-react';
interface CommentSidePanelProps {
@@ -22,13 +23,16 @@ export const CommentSidePanel: React.FC<CommentSidePanelProps> = ({
const [type, setType] = useState<'suggestion' | 'issue' | 'approval'>('suggestion');
const comments = block.comments || [];
const canCompose = canAddBlockComment(currentRole);
const handleSend = () => {
if (!content.trim()) return;
if (!canCompose || !content.trim()) return;
const authorRole = resolveCommentAuthorRole(currentRole);
if (!authorRole) return;
onAddComment({
authorName: currentUserName,
authorRole: currentRole === 'author' ? 'reviewer' : (currentRole as 'reviewer' | 'approver'),
authorRole,
type,
content: content.trim(),
resolved: false,
@@ -163,7 +167,8 @@ export const CommentSidePanel: React.FC<CommentSidePanelProps> = ({
)}
</div>
{/* 底部新增批注输入 */}
{/* 底部新增批注输入:viewer 只读,不提供提交入口 */}
{canCompose ? (
<div style={{ padding: '12px 14px', borderTop: '1px solid var(--border)', background: 'var(--bg-subtle)', display: 'flex', flexDirection: 'column', gap: '8px' }}>
<div style={{ display: 'flex', gap: '4px' }}>
{(['suggestion', 'issue', 'approval'] as const).map((t) => (
@@ -221,6 +226,19 @@ export const CommentSidePanel: React.FC<CommentSidePanelProps> = ({
</button>
</div>
</div>
) : (
<div
style={{
padding: '10px 14px',
borderTop: '1px solid var(--border)',
background: 'var(--bg-subtle)',
fontSize: '12px',
color: 'var(--text-muted)',
}}
>
当前为查阅者,仅可阅读已有批注
</div>
)}
</div>
);
};
+22
View File
@@ -0,0 +1,22 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import {
canAddBlockComment,
resolveCommentAuthorRole,
} from './commentPolicy';
describe('commentPolicy', () => {
it('lets author/reviewer/approver annotate and keeps their comment role', () => {
assert.equal(canAddBlockComment('author'), true);
assert.equal(canAddBlockComment('reviewer'), true);
assert.equal(canAddBlockComment('approver'), true);
assert.equal(resolveCommentAuthorRole('author'), 'reviewer');
assert.equal(resolveCommentAuthorRole('reviewer'), 'reviewer');
assert.equal(resolveCommentAuthorRole('approver'), 'approver');
});
it('keeps viewer read-only: no add entry and no reviewer/approver cast', () => {
assert.equal(canAddBlockComment('viewer'), false);
assert.equal(resolveCommentAuthorRole('viewer'), null);
});
});
+15
View File
@@ -0,0 +1,15 @@
import type { UserRole } from '../types/docgov';
export type CommentAuthorRole = 'reviewer' | 'approver';
/** viewer 只能读批注,不能打开新增入口或提交。 */
export function canAddBlockComment(role: UserRole | string): boolean {
return role === 'author' || role === 'reviewer' || role === 'approver';
}
/** author 的批注按既有语义记为 reviewer;viewer 不得被 cast。 */
export function resolveCommentAuthorRole(role: UserRole | string): CommentAuthorRole | null {
if (role === 'approver') return 'approver';
if (role === 'reviewer' || role === 'author') return 'reviewer';
return null;
}