feat(collab): free-lane chat, claim-on-view, lease ownership, notify inbox
Open member free-lane for chat initiation; auto-claim on author view; harden lease persist/ownership with readable DOC_BUSY; add notification inbox and supplement-impact HTTP decision APIs wired to composition root.
This commit is contained in:
@@ -29,6 +29,7 @@ export interface DocGovRecord {
|
||||
approver_id?: number;
|
||||
approver_name?: string;
|
||||
author_claimed?: boolean;
|
||||
author_claimed_at?: string;
|
||||
reviewer_claimed?: boolean;
|
||||
approver_claimed?: boolean;
|
||||
last_rejected_reason?: string;
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { isProposalType, sortInboxNotifications, type UserNotification } from './notifications';
|
||||
|
||||
describe('notifications inbox helpers', () => {
|
||||
it('ranks action_required / proposal ahead of info', () => {
|
||||
const items: UserNotification[] = [
|
||||
{
|
||||
id: 1,
|
||||
user_id: 1,
|
||||
project_id: 1,
|
||||
type: 'stage_ready_to_advance',
|
||||
title: 'a',
|
||||
body: '',
|
||||
status: 'unread',
|
||||
severity: 'info',
|
||||
created_at: '',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
user_id: 1,
|
||||
project_id: 1,
|
||||
type: 'document_proposal_required',
|
||||
title: 'b',
|
||||
body: '',
|
||||
status: 'unread',
|
||||
severity: 'action_required',
|
||||
created_at: '',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
user_id: 1,
|
||||
project_id: 1,
|
||||
type: 'document_unclaimed_edit_applied',
|
||||
title: 'c',
|
||||
body: '',
|
||||
status: 'unread',
|
||||
severity: 'info',
|
||||
created_at: '',
|
||||
},
|
||||
];
|
||||
const sorted = sortInboxNotifications(items);
|
||||
assert.equal(sorted[0].id, 2);
|
||||
assert.equal(isProposalType(sorted[0].type), true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
import { apiFetch, API_BASE } from './client';
|
||||
import { getBearerAuthHeaders } from './authHeaders';
|
||||
|
||||
export interface UserNotification {
|
||||
id: number;
|
||||
user_id: number;
|
||||
project_id: number;
|
||||
type: string;
|
||||
title: string;
|
||||
body: string;
|
||||
status: string;
|
||||
severity?: 'action_required' | 'info' | string;
|
||||
digest_id?: number;
|
||||
created_at: string;
|
||||
payload?: {
|
||||
stage_key?: string;
|
||||
stage_name?: string;
|
||||
config_revision?: number;
|
||||
workflow_instance_id?: number;
|
||||
doc_id?: string;
|
||||
proposal_id?: string;
|
||||
batch_id?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function sortInboxNotifications(items: UserNotification[]): UserNotification[] {
|
||||
return [...items].sort((a, b) => {
|
||||
const sa = a.severity === 'action_required' || isProposalType(a.type) ? 0 : 1;
|
||||
const sb = b.severity === 'action_required' || isProposalType(b.type) ? 0 : 1;
|
||||
if (sa !== sb) return sa - sb;
|
||||
return b.id - a.id;
|
||||
});
|
||||
}
|
||||
|
||||
export function isProposalType(type: string): boolean {
|
||||
return (
|
||||
type === 'proposal_required' ||
|
||||
type === 'document_proposal_required' ||
|
||||
type === 'digest_proposal_required' ||
|
||||
type.includes('proposal_required')
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchAllNotifications(opts?: {
|
||||
sinceId?: number;
|
||||
limit?: number;
|
||||
scope?: 'all';
|
||||
}): Promise<UserNotification[]> {
|
||||
const params = new URLSearchParams();
|
||||
if (opts?.sinceId && opts.sinceId > 0) params.set('since_id', String(opts.sinceId));
|
||||
if (opts?.limit) params.set('limit', String(opts.limit));
|
||||
if (opts?.scope) params.set('scope', opts.scope);
|
||||
const qs = params.toString();
|
||||
const res = await apiFetch(`${API_BASE}/api/v1/notifications${qs ? `?${qs}` : ''}`, {
|
||||
headers: getBearerAuthHeaders(),
|
||||
});
|
||||
if (!res.ok) {
|
||||
if (res.status === 503) return [];
|
||||
const text = await res.text();
|
||||
throw new Error(text || `获取通知失败: ${res.status}`);
|
||||
}
|
||||
const data = await res.json();
|
||||
const items = Array.isArray(data.items) ? (data.items as UserNotification[]) : [];
|
||||
return sortInboxNotifications(items);
|
||||
}
|
||||
|
||||
export async function patchNotificationStatus(
|
||||
id: number,
|
||||
status: 'read' | 'acted' | 'dismissed' | 'unread'
|
||||
): Promise<void> {
|
||||
const res = await apiFetch(`${API_BASE}/api/v1/notifications/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: getBearerAuthHeaders('application/json'),
|
||||
body: JSON.stringify({ status }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(text || `更新通知失败: ${res.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
export interface SupplementImpactItem {
|
||||
doc_id: string;
|
||||
title?: string;
|
||||
impact_type?: string;
|
||||
reason?: string;
|
||||
author_claimed_snapshot?: boolean;
|
||||
busy_snapshot?: boolean;
|
||||
change_summary?: string;
|
||||
selected?: boolean;
|
||||
outcome?: string;
|
||||
proposal_id?: string;
|
||||
}
|
||||
|
||||
export interface SupplementImpactBatch {
|
||||
batch_id: string;
|
||||
project_id: number;
|
||||
session_id?: string;
|
||||
initiator_user_id: number;
|
||||
source_summary?: string;
|
||||
coverage_status?: string;
|
||||
status: string;
|
||||
decision_nonce: string;
|
||||
decision_digest?: string;
|
||||
revision: number;
|
||||
expires_at: string;
|
||||
items?: SupplementImpactItem[];
|
||||
}
|
||||
|
||||
export async function fetchSupplementImpact(batchId: string): Promise<SupplementImpactBatch> {
|
||||
const res = await apiFetch(`${API_BASE}/api/v1/supplement-impacts/${encodeURIComponent(batchId)}`, {
|
||||
headers: getBearerAuthHeaders(),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(text || `加载补充影响批次失败: ${res.status}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function decideSupplementImpact(
|
||||
batchId: string,
|
||||
body: {
|
||||
kind: 'confirm' | 'cancel';
|
||||
selected_doc_ids: string[];
|
||||
decision_nonce: string;
|
||||
decision_digest: string;
|
||||
expected_revision: number;
|
||||
}
|
||||
): Promise<SupplementImpactBatch> {
|
||||
const res = await apiFetch(
|
||||
`${API_BASE}/api/v1/supplement-impacts/${encodeURIComponent(batchId)}/decision`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: getBearerAuthHeaders('application/json'),
|
||||
body: JSON.stringify(body),
|
||||
}
|
||||
);
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
let message = text || `决策失败: ${res.status}`;
|
||||
try {
|
||||
const parsed = JSON.parse(text) as { message?: string; error?: string };
|
||||
if (parsed.message) message = parsed.message;
|
||||
else if (parsed.error) message = parsed.error;
|
||||
} catch {
|
||||
/* keep */
|
||||
}
|
||||
const err = new Error(message) as Error & { status?: number };
|
||||
err.status = res.status;
|
||||
throw err;
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
@@ -59,6 +59,10 @@ describe('InteractiveCard Logic', () => {
|
||||
const planMeta = getCardTypeMeta('PLAN_CONFIRM');
|
||||
expect(planMeta.label).toBe('计划确认');
|
||||
expect(planMeta.badgeText).toContain('计划');
|
||||
|
||||
const impactMeta = getCardTypeMeta('SUPPLEMENT_IMPACT_CONFIRM');
|
||||
expect(impactMeta.label).toContain('补充影响');
|
||||
expect(impactMeta.badgeText).toContain('影响');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -92,6 +92,16 @@ export function getCardTypeMeta(type: InteractiveCardType) {
|
||||
badgeColor: 'var(--amber-600)',
|
||||
badgeBorder: 'rgba(242, 169, 59, 0.3)',
|
||||
};
|
||||
case 'SUPPLEMENT_IMPACT_CONFIRM':
|
||||
return {
|
||||
label: '补充影响确认',
|
||||
badgeText: '影响确认',
|
||||
icon: FileQuestion,
|
||||
accentColor: 'var(--brand-500)',
|
||||
badgeBg: 'var(--brand-50)',
|
||||
badgeColor: 'var(--brand-600)',
|
||||
badgeBorder: 'var(--brand-100)',
|
||||
};
|
||||
default:
|
||||
return {
|
||||
label: '协同卡片',
|
||||
@@ -566,6 +576,89 @@ export const InteractiveCard: React.FC<InteractiveCardProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{card.cardType === 'SUPPLEMENT_IMPACT_CONFIRM' && Array.isArray(card.impactItems) && (
|
||||
<div
|
||||
data-testid="supplement-impact-checklist"
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '6px',
|
||||
background: 'var(--bg-subtle)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--r-sm)',
|
||||
padding: '8px 10px',
|
||||
}}
|
||||
>
|
||||
{card.coverageStatus && (
|
||||
<div style={{ fontSize: '11px', color: 'var(--text-muted)', marginBottom: 2 }}>
|
||||
覆盖状态:{card.coverageStatus}
|
||||
</div>
|
||||
)}
|
||||
{card.impactItems.map((item) => (
|
||||
<label
|
||||
key={item.docId}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: '8px',
|
||||
fontSize: '12px',
|
||||
color: item.busy ? 'var(--text-muted)' : 'var(--text-strong)',
|
||||
opacity: item.busy ? 0.55 : 1,
|
||||
cursor: item.busy || !canClick ? 'not-allowed' : 'pointer',
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
disabled={item.busy || !canClick}
|
||||
checked={!!item.selected}
|
||||
onChange={(e) => {
|
||||
if (!onExecuteAction || item.busy) return;
|
||||
onExecuteAction(card.cardId, 'toggle_impact_doc', {
|
||||
docId: item.docId,
|
||||
selected: e.target.checked,
|
||||
});
|
||||
}}
|
||||
style={{ marginTop: 2 }}
|
||||
/>
|
||||
<span style={{ flex: 1 }}>
|
||||
<span style={{ fontWeight: 600 }}>{item.title || item.docId}</span>
|
||||
{item.authorClaimed && (
|
||||
<span
|
||||
style={{
|
||||
marginLeft: 6,
|
||||
fontSize: '10.5px',
|
||||
fontWeight: 600,
|
||||
color: 'var(--amber-600)',
|
||||
background: 'var(--amber-50)',
|
||||
padding: '1px 6px',
|
||||
borderRadius: 'var(--r-pill)',
|
||||
}}
|
||||
>
|
||||
已认领
|
||||
</span>
|
||||
)}
|
||||
{item.busy && (
|
||||
<span
|
||||
style={{
|
||||
marginLeft: 6,
|
||||
fontSize: '10.5px',
|
||||
color: 'var(--text-muted)',
|
||||
}}
|
||||
>
|
||||
忙碌中
|
||||
</span>
|
||||
)}
|
||||
{item.changeSummary && (
|
||||
<div style={{ color: 'var(--text-muted)', marginTop: 2, lineHeight: 1.4 }}>
|
||||
{item.changeSummary}
|
||||
</div>
|
||||
)}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 拟建议修改内容 / 批注展示 */}
|
||||
{card.proposedContent && (
|
||||
<div
|
||||
|
||||
@@ -33,6 +33,7 @@ interface DocGovActionBarProps {
|
||||
|
||||
export const DocGovActionBar: React.FC<DocGovActionBarProps> = ({
|
||||
ast,
|
||||
currentRole,
|
||||
currentUserId,
|
||||
canOverrideVisibility = true,
|
||||
onOpenVisibilityModal,
|
||||
@@ -345,6 +346,34 @@ export const DocGovActionBar: React.FC<DocGovActionBarProps> = ({
|
||||
• 已被编制人认领
|
||||
</span>
|
||||
)}
|
||||
{ast.isClaimed &&
|
||||
currentRole === 'author' &&
|
||||
(ast.authorId === undefined || currentUserId === undefined || ast.authorId === currentUserId) && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
fontWeight: 600,
|
||||
padding: '2px 8px',
|
||||
borderRadius: 'var(--r-pill)',
|
||||
background: 'rgba(33, 166, 106, 0.12)',
|
||||
color: 'var(--success, #21a66a)',
|
||||
maxWidth: '280px',
|
||||
lineHeight: 1.35,
|
||||
}}
|
||||
title="认领后他人发起的修改需您确认"
|
||||
>
|
||||
您已于{' '}
|
||||
{ast.authorClaimedAt
|
||||
? new Date(ast.authorClaimedAt).toLocaleString('zh-CN', {
|
||||
month: 'numeric',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
: '此前'}{' '}
|
||||
认领本文档;此后他人发起的修改需您确认。
|
||||
</span>
|
||||
)}
|
||||
{ast.visibilityRule && ast.visibilityRule.mode !== 'inherit' && (
|
||||
<span
|
||||
style={{
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from 'react';
|
||||
import { Sun, Moon, PanelLeftClose, PanelLeftOpen, PanelRightClose, PanelRightOpen, Sparkles } from 'lucide-react';
|
||||
import { ProjectSession } from '../../types/workbench';
|
||||
import { ProjectWorkflowSwitcher } from '../workflow/ProjectWorkflowSwitcher';
|
||||
import { NotificationInbox } from '../notifications/NotificationInbox';
|
||||
|
||||
interface TopbarProps {
|
||||
currentSession: ProjectSession;
|
||||
@@ -165,6 +166,8 @@ export const Topbar: React.FC<TopbarProps> = ({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<NotificationInbox />
|
||||
|
||||
{/* 主题切换按钮 */}
|
||||
<button
|
||||
onClick={onToggleTheme}
|
||||
|
||||
@@ -0,0 +1,349 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Bell, CheckCircle2, ExternalLink, X } from 'lucide-react';
|
||||
import {
|
||||
fetchAllNotifications,
|
||||
isProposalType,
|
||||
patchNotificationStatus,
|
||||
type UserNotification,
|
||||
} from '../../api/notifications';
|
||||
import { decideModificationProposal } from '../../api/docgov';
|
||||
|
||||
const POLL_MS = 20_000;
|
||||
|
||||
export interface NotificationInboxProps {
|
||||
onJumpProject?: (projectId: number, payload?: UserNotification['payload']) => void;
|
||||
}
|
||||
|
||||
export const NotificationInbox: React.FC<NotificationInboxProps> = ({ onJumpProject }) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [items, setItems] = useState<UserNotification[]>([]);
|
||||
const [busyId, setBusyId] = useState<number | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const next = await fetchAllNotifications({ limit: 50, scope: 'all' });
|
||||
setItems(next);
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : '加载通知失败');
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
const timer = window.setInterval(() => {
|
||||
void load();
|
||||
}, POLL_MS);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [load]);
|
||||
|
||||
const unread = useMemo(
|
||||
() => items.filter((n) => n.status === 'unread').length,
|
||||
[items]
|
||||
);
|
||||
const actionItems = useMemo(
|
||||
() =>
|
||||
items.filter(
|
||||
(n) => n.severity === 'action_required' || isProposalType(n.type)
|
||||
),
|
||||
[items]
|
||||
);
|
||||
const infoItems = useMemo(
|
||||
() =>
|
||||
items.filter(
|
||||
(n) => !(n.severity === 'action_required' || isProposalType(n.type))
|
||||
),
|
||||
[items]
|
||||
);
|
||||
|
||||
const markRead = async (n: UserNotification) => {
|
||||
if (n.status !== 'unread') return;
|
||||
try {
|
||||
await patchNotificationStatus(n.id, 'read');
|
||||
setItems((prev) => prev.map((x) => (x.id === n.id ? { ...x, status: 'read' } : x)));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
|
||||
const decideProposal = async (n: UserNotification, approve: boolean) => {
|
||||
const proposalId = n.payload?.proposal_id;
|
||||
if (!proposalId) return;
|
||||
setBusyId(n.id);
|
||||
try {
|
||||
await decideModificationProposal({ proposal_id: proposalId, approve });
|
||||
await patchNotificationStatus(n.id, 'acted');
|
||||
setItems((prev) => prev.map((x) => (x.id === n.id ? { ...x, status: 'acted' } : x)));
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : '提案决策失败');
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
title="通知收件箱"
|
||||
data-testid="notification-inbox-bell"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
borderRadius: 'var(--r-sm)',
|
||||
color: 'var(--text-muted)',
|
||||
background: open ? 'var(--brand-50)' : 'var(--bg-subtle)',
|
||||
border: '1px solid var(--border)',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
<Bell size={16} />
|
||||
{unread > 0 && (
|
||||
<span
|
||||
data-testid="notification-unread-badge"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '-4px',
|
||||
right: '-4px',
|
||||
minWidth: '16px',
|
||||
height: '16px',
|
||||
padding: '0 4px',
|
||||
borderRadius: '999px',
|
||||
background: 'var(--danger, #dc2626)',
|
||||
color: '#fff',
|
||||
fontSize: '10px',
|
||||
fontWeight: 700,
|
||||
lineHeight: '16px',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{unread > 99 ? '99+' : unread}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
data-testid="notification-inbox-panel"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '40px',
|
||||
right: 0,
|
||||
width: '360px',
|
||||
maxHeight: '480px',
|
||||
overflow: 'auto',
|
||||
background: 'var(--bg-panel)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--r-md)',
|
||||
boxShadow: 'var(--shadow-md, 0 8px 24px rgba(15,23,42,0.12))',
|
||||
zIndex: 50,
|
||||
padding: '10px 0',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '0 12px 8px',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: '13px', fontWeight: 700, color: 'var(--text-strong)' }}>
|
||||
通知
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(false)}
|
||||
style={{
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
color: 'var(--text-muted)',
|
||||
cursor: 'pointer',
|
||||
padding: 4,
|
||||
}}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div style={{ padding: '8px 12px', fontSize: '12px', color: 'var(--danger, #dc2626)' }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{actionItems.length > 0 && (
|
||||
<section style={{ padding: '8px 0' }}>
|
||||
<div
|
||||
style={{
|
||||
padding: '4px 12px',
|
||||
fontSize: '11px',
|
||||
fontWeight: 700,
|
||||
color: 'var(--amber-600, #d97706)',
|
||||
letterSpacing: '0.02em',
|
||||
}}
|
||||
>
|
||||
需要处理
|
||||
</div>
|
||||
{actionItems.map((n) => (
|
||||
<NotificationRow
|
||||
key={n.id}
|
||||
n={n}
|
||||
busy={busyId === n.id}
|
||||
onOpen={() => {
|
||||
void markRead(n);
|
||||
onJumpProject?.(n.project_id, n.payload);
|
||||
}}
|
||||
onDecide={
|
||||
isProposalType(n.type) && n.payload?.proposal_id
|
||||
? (approve) => void decideProposal(n, approve)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section style={{ padding: '8px 0' }}>
|
||||
<div
|
||||
style={{
|
||||
padding: '4px 12px',
|
||||
fontSize: '11px',
|
||||
fontWeight: 700,
|
||||
color: 'var(--text-muted)',
|
||||
}}
|
||||
>
|
||||
摘要
|
||||
</div>
|
||||
{infoItems.length === 0 && actionItems.length === 0 ? (
|
||||
<div style={{ padding: '16px 12px', fontSize: '12px', color: 'var(--text-muted)' }}>
|
||||
暂无通知
|
||||
</div>
|
||||
) : (
|
||||
infoItems.map((n) => (
|
||||
<NotificationRow
|
||||
key={n.id}
|
||||
n={n}
|
||||
busy={false}
|
||||
onOpen={() => {
|
||||
void markRead(n);
|
||||
onJumpProject?.(n.project_id, n.payload);
|
||||
}}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function NotificationRow({
|
||||
n,
|
||||
busy,
|
||||
onOpen,
|
||||
onDecide,
|
||||
}: {
|
||||
n: UserNotification;
|
||||
busy: boolean;
|
||||
onOpen: () => void;
|
||||
onDecide?: (approve: boolean) => void;
|
||||
}) {
|
||||
const unread = n.status === 'unread';
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
padding: '10px 12px',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
background: unread ? 'var(--brand-50, rgba(79,107,255,0.06))' : 'transparent',
|
||||
opacity: busy ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'flex-start' }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '12.5px',
|
||||
fontWeight: unread ? 700 : 600,
|
||||
color: 'var(--text-strong)',
|
||||
marginBottom: 2,
|
||||
}}
|
||||
>
|
||||
{n.title}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '11.5px',
|
||||
color: 'var(--text-muted)',
|
||||
lineHeight: 1.45,
|
||||
whiteSpace: 'pre-wrap',
|
||||
}}
|
||||
>
|
||||
{n.body}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpen}
|
||||
title="跳转"
|
||||
style={{
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
color: 'var(--brand-600)',
|
||||
cursor: 'pointer',
|
||||
padding: 2,
|
||||
}}
|
||||
>
|
||||
<ExternalLink size={14} />
|
||||
</button>
|
||||
</div>
|
||||
{onDecide && n.status !== 'acted' && (
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => onDecide(true)}
|
||||
style={{
|
||||
fontSize: '11.5px',
|
||||
fontWeight: 600,
|
||||
padding: '4px 10px',
|
||||
borderRadius: 'var(--r-sm)',
|
||||
border: '1px solid var(--brand-100)',
|
||||
background: 'var(--brand-500)',
|
||||
color: '#fff',
|
||||
cursor: busy ? 'not-allowed' : 'pointer',
|
||||
}}
|
||||
>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
|
||||
<CheckCircle2 size={12} /> 同意修改
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => onDecide(false)}
|
||||
style={{
|
||||
fontSize: '11.5px',
|
||||
fontWeight: 600,
|
||||
padding: '4px 10px',
|
||||
borderRadius: 'var(--r-sm)',
|
||||
border: '1px solid var(--border)',
|
||||
background: 'var(--bg-subtle)',
|
||||
color: 'var(--text-muted)',
|
||||
cursor: busy ? 'not-allowed' : 'pointer',
|
||||
}}
|
||||
>
|
||||
拒绝
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -153,6 +153,23 @@ test('read-only current-doc request does not start a task', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('summary/analysis with write verbs stays chat (read-only priority)', () => {
|
||||
const phrases = [
|
||||
'请总结并生成要点',
|
||||
'分析一下这份附件然后编写摘要',
|
||||
'概括文档内容并更新说明',
|
||||
'查看附件后修改建议列表',
|
||||
];
|
||||
for (const text of phrases) {
|
||||
assert.equal(hasDocumentWriteIntent(text), false, text);
|
||||
assert.equal(resolveTaskTemplateForMessage(text, bothPool, activeIntake), undefined, text);
|
||||
}
|
||||
});
|
||||
|
||||
test('pure write intent still routes to task', () => {
|
||||
assert.equal(hasDocumentWriteIntent('请编制用户初始需求收集表'), true);
|
||||
});
|
||||
|
||||
test('template-only active slot is not pinned; keywords still apply', () => {
|
||||
assert.equal(isServerRegisteredControlledDoc(templateIntakeSlot), false);
|
||||
const resolved = resolveTaskTemplateForMessage(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { DeliverableItem } from '../types/workbench';
|
||||
|
||||
const READ_ONLY_INTENT =
|
||||
/(?:总结|概括|查看|看看|展示|读取|介绍|解释|分析|评价|检查|查询|当前状态|进度|谁在编辑|是否在编辑)/;
|
||||
/(?:总结|概括|摘要|要点|简述|解读|查看|看看|展示|读取|介绍|解释|分析|评价|检查|查询|当前状态|进度|谁在编辑|是否在编辑)/;
|
||||
const WRITE_INTENT =
|
||||
// 弱写动词(整理/梳理/汇总等)故意不含:走聊天通道,由后端阶段感知 + 确认门禁引导。
|
||||
/(?:开始|编写|编制|生成|起草|创建|撰写|重写|更新|修改|完善|补充|刷新|重做|改写)/;
|
||||
@@ -189,9 +189,10 @@ export function wrongWorkflowGuidance(info: NonNullable<ResolvedDocumentTask['wr
|
||||
return `您提到的文档「${info.docTitle}」属于【${workflowDisplayLabel(info.ownerWorkflow)}】,当前激活工作流为【${workflowDisplayLabel(info.activeWorkflow)}】。已阻止跨工作流操作,请先切换到对应工作流后再操作,或改为点名当前工作流的文档。`;
|
||||
}
|
||||
|
||||
/** 只读动词优先;无明确写动作则不启动文档任务。 */
|
||||
/** 只读动词优先于写动词:含「总结/分析/查看」等时即使夹杂写动词也不启动文档任务。 */
|
||||
export function hasDocumentWriteIntent(text: string): boolean {
|
||||
const normalized = text.trim();
|
||||
if (!normalized) return false;
|
||||
if (READ_ONLY_INTENT.test(normalized)) return false;
|
||||
return WRITE_INTENT.test(normalized);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
fetchIntentPlan,
|
||||
submitIntentPlanDecision,
|
||||
} from '../api/intentPlans';
|
||||
import { decideSupplementImpact } from '../api/notifications';
|
||||
import {
|
||||
ALL_MOCK_DOCUMENTS,
|
||||
} from '@demo-seeds';
|
||||
@@ -72,6 +73,107 @@ export function useCardActions(input: CardActionsInput) {
|
||||
|
||||
if (!targetCard) return;
|
||||
|
||||
if (targetCard.cardType === 'SUPPLEMENT_IMPACT_CONFIRM') {
|
||||
const batchId = String(payload?.batch_id || targetCard.supplementBatchId || '');
|
||||
if (actionId === 'toggle_impact_doc') {
|
||||
const docId = String(payload?.docId || '');
|
||||
const selected = !!payload?.selected;
|
||||
setMessages((prev) =>
|
||||
prev.map((m) => {
|
||||
if (m.interactiveCard?.cardId !== cardId || !m.interactiveCard?.impactItems) return m;
|
||||
return {
|
||||
...m,
|
||||
interactiveCard: {
|
||||
...m.interactiveCard,
|
||||
impactItems: m.interactiveCard.impactItems.map((it) =>
|
||||
it.docId === docId ? { ...it, selected } : it
|
||||
),
|
||||
},
|
||||
};
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
const kind = (payload?.kind || (actionId === 'cancel' || actionId === 'supplement_cancel' ? 'cancel' : 'confirm')) as
|
||||
| 'confirm'
|
||||
| 'cancel';
|
||||
const expectedRevision = Number(payload?.expected_revision || targetCard.planRevision || 0);
|
||||
const decisionNonce =
|
||||
String(payload?.decision_nonce || targetCard.decisionNonce || '') ||
|
||||
(typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
|
||||
? crypto.randomUUID()
|
||||
: `${cardId}:${actionId}:${expectedRevision}:${Date.now()}`);
|
||||
const selectedDocIds =
|
||||
kind === 'confirm'
|
||||
? (targetCard.impactItems || [])
|
||||
.filter((it) => it.selected && !it.busy)
|
||||
.map((it) => it.docId)
|
||||
: [];
|
||||
const decisionDigest =
|
||||
String(payload?.decision_digest || targetCard.decisionDigest || '') ||
|
||||
`${kind}:${expectedRevision}:${selectedDocIds.join(',')}`;
|
||||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.interactiveCard?.cardId === cardId
|
||||
? { ...m, interactiveCard: { ...m.interactiveCard!, status: 'executing' } }
|
||||
: m
|
||||
)
|
||||
);
|
||||
void (async () => {
|
||||
try {
|
||||
const batch = await decideSupplementImpact(batchId, {
|
||||
kind,
|
||||
selected_doc_ids: selectedDocIds,
|
||||
decision_nonce: decisionNonce,
|
||||
decision_digest: decisionDigest,
|
||||
expected_revision: expectedRevision,
|
||||
});
|
||||
setMessages((prev) =>
|
||||
prev.map((m) => {
|
||||
if (m.interactiveCard?.cardId !== cardId || !m.interactiveCard) return m;
|
||||
return {
|
||||
...m,
|
||||
interactiveCard: {
|
||||
...m.interactiveCard,
|
||||
status: batch.status === 'cancelled' ? 'cancelled' : 'resolved',
|
||||
planRevision: batch.revision,
|
||||
decisionNonce: batch.decision_nonce,
|
||||
resolutionSummary:
|
||||
kind === 'cancel' ? '已取消补充影响确认' : '补充影响决策已提交',
|
||||
resolvedBy: currentUser.name,
|
||||
resolvedAt: new Date().toTimeString().substring(0, 5),
|
||||
impactItems: (batch.items || []).map((it) => ({
|
||||
docId: it.doc_id,
|
||||
title: it.title,
|
||||
authorClaimed: it.author_claimed_snapshot,
|
||||
busy: it.busy_snapshot,
|
||||
changeSummary: it.change_summary,
|
||||
selected: it.selected,
|
||||
})),
|
||||
},
|
||||
};
|
||||
})
|
||||
);
|
||||
} catch {
|
||||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.interactiveCard?.cardId === cardId && m.interactiveCard
|
||||
? {
|
||||
...m,
|
||||
interactiveCard: {
|
||||
...m.interactiveCard,
|
||||
status: 'active',
|
||||
resolutionSummary: '补充影响决策失败,请重试',
|
||||
},
|
||||
}
|
||||
: m
|
||||
)
|
||||
);
|
||||
}
|
||||
})();
|
||||
return;
|
||||
}
|
||||
|
||||
if (targetCard.cardType === 'RECHECK_NOTICE' && actionId === 'recheck_no_change') {
|
||||
const projectId = Number(payload?.project_id || currentProjectId);
|
||||
const markId = Number(payload?.mark_id);
|
||||
|
||||
@@ -49,6 +49,7 @@ export function useDocumentLifecycleActions(input: DocumentLifecycleInput): Docu
|
||||
...docAST,
|
||||
lifecycleState: nextState,
|
||||
isClaimed: nextClaimed,
|
||||
authorClaimedAt: doc.author_claimed_at || docAST.authorClaimedAt,
|
||||
authorId: doc.author_id ?? docAST.authorId,
|
||||
author: doc.author_name || docAST.author,
|
||||
reviewerId: doc.reviewer_id ?? docAST.reviewerId,
|
||||
|
||||
@@ -8,8 +8,9 @@ test('sending a message lives in one hook', () => {
|
||||
assert.equal((app.match(/useSendMessage\(/g) || []).length, 1);
|
||||
assert.equal(app.includes('附件服务端注册失败'), false);
|
||||
assert.equal(hook.includes('registerUploadAttachments'), true);
|
||||
assert.equal(hook.includes('hasDocumentWriteIntent'), true);
|
||||
assert.equal(hook.includes('runTaskAttempt'), true);
|
||||
assert.equal(hook.includes('runStreamAttempt'), true);
|
||||
assert.equal(hook.includes('失败硬拦'), true);
|
||||
assert.equal(hook.includes('写意图失败硬拦'), true);
|
||||
assert.equal(hook.includes('handlePassReview'), true);
|
||||
});
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
isAffirmativeResponse,
|
||||
} from '../engine/cardActionEngine';
|
||||
import { markdownToBlockAST } from '../engine/markdownToBlock';
|
||||
import { resolveTaskTemplateForMessage, wrongWorkflowGuidance } from '../engine/templateMatch';
|
||||
import { hasDocumentWriteIntent, resolveTaskTemplateForMessage, wrongWorkflowGuidance } from '../engine/templateMatch';
|
||||
import { createClientTurnId } from '../lib/createClientTurnId';
|
||||
import { canonicalDocIdForTask } from '../lib/controlledDocHydrate';
|
||||
import { holdsDocDuty } from '../lib/docGovActionVisibility';
|
||||
@@ -170,7 +170,7 @@ export function useSendMessage(input: SendMessageInput) {
|
||||
};
|
||||
}
|
||||
|
||||
// 附件必须先注册为服务端 freeform(server docId);失败硬拦,禁止本地 orphan 回退
|
||||
// 附件优先注册为 freeform;写意图失败硬拦,只读/对话路径注册失败不阻断发送。
|
||||
let processedAttachments = attachments;
|
||||
if (attachments && attachments.length > 0) {
|
||||
let registered;
|
||||
@@ -183,74 +183,80 @@ export function useSendMessage(input: SendMessageInput) {
|
||||
createFreeformDoc,
|
||||
});
|
||||
} catch (err) {
|
||||
return {
|
||||
accepted: false,
|
||||
error: err instanceof Error ? err.message : '附件服务端注册失败,请重试',
|
||||
};
|
||||
if (hasDocumentWriteIntent(trimmed)) {
|
||||
return {
|
||||
accepted: false,
|
||||
error: err instanceof Error ? err.message : '附件服务端注册失败,请重试',
|
||||
};
|
||||
}
|
||||
console.warn('[upload] freeform register failed; continuing chat/summary', err);
|
||||
registered = undefined;
|
||||
}
|
||||
|
||||
const newDeliverablesList = registered.map((r) => r.deliverable);
|
||||
const newDocCacheEntries: Record<string, DocumentAST> = {};
|
||||
for (const r of registered) {
|
||||
const baseTitle = r.deliverable.title;
|
||||
const initialMd = r.detail.content_markdown;
|
||||
const newAST = markdownToBlockAST(initialMd, {
|
||||
docId: r.serverDocId,
|
||||
title: baseTitle,
|
||||
taskKey: 'user_requirements',
|
||||
version: 'V1.0',
|
||||
category: 'private',
|
||||
creatorId: currentUser.id,
|
||||
creatorName: currentUser.name,
|
||||
lifecycleState: 'drafting',
|
||||
isClaimed: true,
|
||||
author: currentUser.name,
|
||||
reviewer: '',
|
||||
approver: '',
|
||||
});
|
||||
newAST.governanceMode = 'freeform';
|
||||
newAST.space = 'my_private';
|
||||
newDocCacheEntries[r.serverDocId] = newAST;
|
||||
if (r.attachment.fileRaw) {
|
||||
saveFile(r.serverDocId, r.attachment.fileRaw, r.attachment.name).catch((e) =>
|
||||
console.warn('[fileStore] 保存上传文件失败:', r.serverDocId, e),
|
||||
);
|
||||
// 二进制同步登记 CAS,刷新后可经 raw 端点回退预览
|
||||
const extMatch = r.attachment.name.match(/\.([^.]+)$/);
|
||||
const ext = extMatch ? extMatch[1].toLowerCase() : '';
|
||||
const TEXT_EXTS = new Set(['txt', 'md', 'csv', 'json', 'xml', 'yaml', 'yml', 'log']);
|
||||
if (!TEXT_EXTS.has(ext) && currentProjectId > 0) {
|
||||
try {
|
||||
const uploaded = await uploadProjectAsset(
|
||||
currentProjectId,
|
||||
r.attachment.fileRaw as Blob,
|
||||
r.attachment.name,
|
||||
);
|
||||
if (r.deliverable.fileMeta) {
|
||||
r.deliverable.fileMeta.assetId = uploaded.asset_id;
|
||||
if (registered) {
|
||||
const newDeliverablesList = registered.map((r) => r.deliverable);
|
||||
const newDocCacheEntries: Record<string, DocumentAST> = {};
|
||||
for (const r of registered) {
|
||||
const baseTitle = r.deliverable.title;
|
||||
const initialMd = r.detail.content_markdown;
|
||||
const newAST = markdownToBlockAST(initialMd, {
|
||||
docId: r.serverDocId,
|
||||
title: baseTitle,
|
||||
taskKey: 'user_requirements',
|
||||
version: 'V1.0',
|
||||
category: 'private',
|
||||
creatorId: currentUser.id,
|
||||
creatorName: currentUser.name,
|
||||
lifecycleState: 'drafting',
|
||||
isClaimed: true,
|
||||
author: currentUser.name,
|
||||
reviewer: '',
|
||||
approver: '',
|
||||
});
|
||||
newAST.governanceMode = 'freeform';
|
||||
newAST.space = 'my_private';
|
||||
newDocCacheEntries[r.serverDocId] = newAST;
|
||||
if (r.attachment.fileRaw) {
|
||||
saveFile(r.serverDocId, r.attachment.fileRaw, r.attachment.name).catch((e) =>
|
||||
console.warn('[fileStore] 保存上传文件失败:', r.serverDocId, e),
|
||||
);
|
||||
// 二进制同步登记 CAS,刷新后可经 raw 端点回退预览
|
||||
const extMatch = r.attachment.name.match(/\.([^.]+)$/);
|
||||
const ext = extMatch ? extMatch[1].toLowerCase() : '';
|
||||
const TEXT_EXTS = new Set(['txt', 'md', 'csv', 'json', 'xml', 'yaml', 'yml', 'log']);
|
||||
if (!TEXT_EXTS.has(ext) && currentProjectId > 0) {
|
||||
try {
|
||||
const uploaded = await uploadProjectAsset(
|
||||
currentProjectId,
|
||||
r.attachment.fileRaw as Blob,
|
||||
r.attachment.name,
|
||||
);
|
||||
if (r.deliverable.fileMeta) {
|
||||
r.deliverable.fileMeta.assetId = uploaded.asset_id;
|
||||
}
|
||||
// 回填去重与解析状态,供消息附件卡片展示提示。
|
||||
r.attachment.assetId = uploaded.asset_id;
|
||||
r.attachment.deduplicated = uploaded.deduplicated === true;
|
||||
r.attachment.parseState = uploaded.parse_state;
|
||||
} catch (e) {
|
||||
console.warn('[assets] 上传附件到 CAS 失败(预览回退可能不可用):', e);
|
||||
}
|
||||
// 回填去重与解析状态,供消息附件卡片展示提示。
|
||||
r.attachment.assetId = uploaded.asset_id;
|
||||
r.attachment.deduplicated = uploaded.deduplicated === true;
|
||||
r.attachment.parseState = uploaded.parse_state;
|
||||
} catch (e) {
|
||||
console.warn('[assets] 上传附件到 CAS 失败(预览回退可能不可用):', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
processedAttachments = registered.map((r) => r.attachment);
|
||||
const newDocIds = new Set(newDeliverablesList.map((d) => d.docId));
|
||||
setDeliverables((prev) => [...newDeliverablesList, ...prev.filter((d) => !newDocIds.has(d.docId))]);
|
||||
setDocCache((prev) => ({
|
||||
...prev,
|
||||
...newDocCacheEntries,
|
||||
}));
|
||||
processedAttachments = registered.map((r) => r.attachment);
|
||||
const newDocIds = new Set(newDeliverablesList.map((d) => d.docId));
|
||||
setDeliverables((prev) => [...newDeliverablesList, ...prev.filter((d) => !newDocIds.has(d.docId))]);
|
||||
setDocCache((prev) => ({
|
||||
...prev,
|
||||
...newDocCacheEntries,
|
||||
}));
|
||||
|
||||
if (processedAttachments.length > 0 && processedAttachments[0].docId) {
|
||||
setActiveDocId(processedAttachments[0].docId);
|
||||
setActiveCategory('private');
|
||||
if (processedAttachments.length > 0 && processedAttachments[0].docId) {
|
||||
setActiveDocId(processedAttachments[0].docId);
|
||||
setActiveCategory('private');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,22 @@ test('403 revision denial is not reported as a network error', () => {
|
||||
assert.match(info.userMessage, /无权访问该文档/);
|
||||
});
|
||||
|
||||
test('403 author-duty denial surfaces draft-vs-submit guidance', () => {
|
||||
const info = buildNetworkErrorInfo(403, {
|
||||
errorCode: 'ErrOnlyAuthorCanEdit',
|
||||
detail: '',
|
||||
});
|
||||
assert.equal(info.code, 'ErrOnlyAuthorCanEdit');
|
||||
assert.match(info.userMessage, /送审需编制人/);
|
||||
assert.notEqual(info.code, 'NETWORK_ERROR');
|
||||
});
|
||||
|
||||
test('DOC_DUTY_FORBIDDEN keeps draft-vs-submit guidance', () => {
|
||||
const info = buildNetworkErrorInfo(403, { errorCode: 'DOC_DUTY_FORBIDDEN' });
|
||||
assert.equal(info.code, 'DOC_DUTY_FORBIDDEN');
|
||||
assert.match(info.userMessage, /可生成\/编写草稿/);
|
||||
});
|
||||
|
||||
test('TASK_SCOPE_UNRESOLVED names the missing workflow stage', () => {
|
||||
const info = buildNetworkErrorInfo(409, { errorCode: 'TASK_SCOPE_UNRESOLVED' });
|
||||
assert.equal(info.code, 'TASK_SCOPE_UNRESOLVED');
|
||||
|
||||
@@ -35,6 +35,22 @@ const fatalDenialCopy: Record<string, string> = {
|
||||
TASK_SCOPE_UNRESOLVED: '无法定位该文档任务的工作流阶段,请检查任务名或联系管理员',
|
||||
};
|
||||
|
||||
/** 编制人身份类错误:可生成/编写草稿,送审仍须编制人。 */
|
||||
const authorDutyCopy =
|
||||
'您不是该文档编制人,可生成/编写草稿,但送审需编制人操作';
|
||||
|
||||
const authorDutyErrorCodes = new Set([
|
||||
'DOC_DUTY_FORBIDDEN',
|
||||
'only_author_can_edit',
|
||||
'ErrOnlyAuthorCanEdit',
|
||||
'reviewer_cannot_edit',
|
||||
'ErrReviewerCannotEdit',
|
||||
'approver_cannot_edit',
|
||||
'ErrApproverCannotEdit',
|
||||
'unauthorized_actor',
|
||||
'ErrUnauthorizedActor',
|
||||
]);
|
||||
|
||||
/** 任务直通道失败后应降级到聊天确认引导的错误码(仅降级一次,防循环)。 */
|
||||
export function isTaskStageDegradeCode(code: string | undefined | null): boolean {
|
||||
return code === 'TASK_SCOPE_UNRESOLVED' || code === 'DOC_TASK_NOT_IN_ACTIVE_STAGE';
|
||||
@@ -48,7 +64,7 @@ export function buildNetworkErrorInfo(
|
||||
const detail = opts?.detail || '';
|
||||
if (code === 'DOC_BUSY' || code === 'document_busy') {
|
||||
return {
|
||||
userMessage: detail || '该文档正在被其他用户或项目助手编辑,当前请求未创建新任务',
|
||||
userMessage: detail || '文档正在修改中(由项目助手或其他用户发起)。您的修改请求未执行,请稍后重试。',
|
||||
code: 'DOC_BUSY',
|
||||
class: 'fatal',
|
||||
actions: [],
|
||||
@@ -62,16 +78,19 @@ export function buildNetworkErrorInfo(
|
||||
actions: ['retry'],
|
||||
};
|
||||
}
|
||||
if (code === 'DOC_DUTY_FORBIDDEN') {
|
||||
if (code === 'DOC_DUTY_FORBIDDEN' || authorDutyErrorCodes.has(code)) {
|
||||
return {
|
||||
userMessage: detail || '当前账号不是该文档任务的职责人,无法执行',
|
||||
code,
|
||||
userMessage: detail || authorDutyCopy,
|
||||
code: code || 'DOC_DUTY_FORBIDDEN',
|
||||
class: 'fatal',
|
||||
actions: ['retry'],
|
||||
};
|
||||
}
|
||||
if (fatalDenialCopy[code]) {
|
||||
const retryable = code === 'WORKFLOW_NOT_ACTIVATED' || code === 'DOC_DUTY_NOT_MANAGED' || code === 'TASK_SCOPE_UNRESOLVED';
|
||||
const retryable =
|
||||
code === 'WORKFLOW_NOT_ACTIVATED' ||
|
||||
code === 'DOC_DUTY_NOT_MANAGED' ||
|
||||
code === 'TASK_SCOPE_UNRESOLVED';
|
||||
return {
|
||||
userMessage: detail || fatalDenialCopy[code],
|
||||
code,
|
||||
@@ -96,6 +115,14 @@ export function buildNetworkErrorInfo(
|
||||
actions: ['retry'],
|
||||
};
|
||||
}
|
||||
if (authorDutyErrorCodes.has(code) || /编制|职责|送审|审核|批准/.test(detail)) {
|
||||
return {
|
||||
userMessage: detail || authorDutyCopy,
|
||||
code: code || 'DOC_DUTY_FORBIDDEN',
|
||||
class: 'fatal',
|
||||
actions: ['retry'],
|
||||
};
|
||||
}
|
||||
return {
|
||||
userMessage: detail || '无权执行该操作,请确认账号权限与工作流阶段',
|
||||
code: code || 'ERR_FORBIDDEN',
|
||||
|
||||
@@ -59,6 +59,8 @@ export interface DocumentAST {
|
||||
sharedAt?: string;
|
||||
lifecycleState: DocLifecycleState;
|
||||
isClaimed: boolean;
|
||||
/** ISO time when current author claimed; optional from server */
|
||||
authorClaimedAt?: string;
|
||||
author: string;
|
||||
authorId?: number;
|
||||
reviewer: string;
|
||||
|
||||
@@ -85,7 +85,8 @@ export type InteractiveCardType =
|
||||
| 'SLOT_FILLING'
|
||||
| 'DIFF_PROPOSAL'
|
||||
| 'REMINDER_NOTICE'
|
||||
| 'RECHECK_NOTICE';
|
||||
| 'RECHECK_NOTICE'
|
||||
| 'SUPPLEMENT_IMPACT_CONFIRM';
|
||||
|
||||
export type CardActionStatus =
|
||||
| 'active'
|
||||
@@ -124,6 +125,19 @@ export interface InteractiveCardData {
|
||||
planRevision?: number;
|
||||
stepCount?: number;
|
||||
validatedOnly?: boolean;
|
||||
/** SUPPLEMENT_IMPACT_CONFIRM */
|
||||
supplementBatchId?: string;
|
||||
decisionNonce?: string;
|
||||
decisionDigest?: string;
|
||||
coverageStatus?: string;
|
||||
impactItems?: Array<{
|
||||
docId: string;
|
||||
title?: string;
|
||||
authorClaimed?: boolean;
|
||||
busy?: boolean;
|
||||
changeSummary?: string;
|
||||
selected?: boolean;
|
||||
}>;
|
||||
}
|
||||
|
||||
export type AssistantMessageStatus =
|
||||
|
||||
Reference in New Issue
Block a user