前端:任务详情展示审批/驳回记录 + 任务行 ID 不被长标题挤掉 + i18n 五语 (tsk_EmZ6vjtVbrOk)

- TaskDetail 渲染 _raw.approvals:闸名·通过/驳回 + 审批人 + 时间,
  驳回用红色边框+红底高亮块突出 reason(t.rejectReason 标签),
  通过用绿色、reason 走低调 ↳ 行;无审批记录不渲染该分区。
- 安全:reason/actor/gate 全部走 React 文本节点(默认转义),
  不经 MaestroRichDoc/innerHTML,避免同源存储型 XSS。
- TaskRow:taskid 拆为独立 flex:none span,title 加 minWidth:0,
  长标题省略号截断时 id 完整不被挤掉;ArchiveRow 同步处理保持一致。
- i18n:zh/en/es/ja/fr 五语新增 rejectReason 键。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-06-30 09:04:42 +08:00
parent e115e065e6
commit 0c1f630dca
3 changed files with 51 additions and 9 deletions
+43 -2
View File
@@ -227,9 +227,10 @@ function TaskRow({ task, depth, ctx, expandedId, setExpandedId, openIds, toggleO
}}>
{kids.length ? '▶' : '·'}
</span>
<span style={{ fontWeight: 500, color: ctx ? 'var(--muted)' : 'var(--ink)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{task.title}<span style={{ color: 'var(--faint)', fontSize: 10.5, marginLeft: 6 }}>{task.id}</span>
<span style={{ flex: '0 1 auto', minWidth: 0, fontWeight: 500, color: ctx ? 'var(--muted)' : 'var(--ink)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{task.title}
</span>
<span style={{ flex: 'none', color: 'var(--faint)', fontSize: 10.5 }}>{task.id}</span>
<span style={{ flex: 1, minWidth: 8 }}></span>
{task.deps ? (
<span style={{ flex: 'none', fontSize: 10.5, letterSpacing: '.06em', color: 'var(--amber)', border: '1px dashed var(--amber-dim)', borderRadius: 3, padding: '1px 7px', lineHeight: 1.5, whiteSpace: 'nowrap' }}>
@@ -384,6 +385,46 @@ function TaskDetail({ task, t, byId, onJump, onTakeover, taskOps }) {
</div>
</div>
) : null}
{/* 审批记录:读 _raw.approvals,按记录列出闸名·通过/驳回 + 审批人 + 时间;驳回突出 reason。
安全:reason/actor/gate 全部走 React 文本节点(默认转义),绝不经 MaestroRichDoc / innerHTML,避免同源存储型 XSS。 */}
{(() => {
const approvals = (task._raw && task._raw.approvals) || [];
if (!approvals.length) return null;
return (
<div>
<div style={{ fontSize: 10.5, color: 'var(--muted)', letterSpacing: '.18em', marginBottom: 6 }}>{t.approvalsLabel}</div>
<div style={{ display: 'grid', gap: 6 }}>
{approvals.map((a, i) => {
const rej = a.action === 'reject';
const at = (a.at || '').replace('T', ' ').slice(0, 19);
const gate = (t.gates && t.gates[a.gate]) || a.gate;
return (
<div key={i} style={{
fontSize: 12, padding: '6px 10px', borderRadius: 4,
background: 'var(--bg-deep)', border: '1px solid var(--line-soft)',
borderLeft: '2px solid ' + (rej ? 'var(--red-dim)' : 'var(--green-dim)'),
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<span style={{ color: rej ? 'var(--red)' : 'var(--green)', fontWeight: 700, flex: 'none' }}>
{gate} · {rej ? t.rejected : t.accepted}
</span>
<span style={{ color: 'var(--faint)', fontSize: 11 }}>{t.approver} {a.actor} · {at}</span>
</div>
{rej && a.reason ? (
<div style={{ marginTop: 5, padding: '5px 9px', borderRadius: 3,
background: 'rgba(240,100,100,.08)', borderLeft: '2px solid var(--red-dim)', color: 'var(--amber)' }}>
<span style={{ color: 'var(--red)', fontWeight: 600, marginRight: 6 }}>{t.rejectReason}</span>{a.reason}
</div>
) : a.reason ? (
<div style={{ marginTop: 4, color: 'var(--muted)' }}> {a.reason}</div>
) : null}
</div>
);
})}
</div>
</div>
);
})()}
</div>
);
}