فهرست منبع

feat(reader): 阅读页译文评估对勾,按分数着色,点击弹出分数、方法与理由

复用 issue 的弹层(桌面 popover / 手机抽屉);分档以后端输出的 data-level 为准,
前端不重复阈值。未评估句子的灰色对勾不能点。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
visuddhinanda 15 ساعت پیش
والد
کامیت
fc4381a0d0

+ 38 - 0
api-v13/resources/css/modules/reader-content.css

@@ -500,3 +500,41 @@ article.reader-body span.anno-issue-mark.is-active {
 .anno-issue-item.anno-issue-error      { --anno-issue-color: var(--tblr-danger); }
 .anno-issue-item.anno-issue-warning    { --anno-issue-color: var(--tblr-warning); }
 .anno-issue-item.anno-issue-suggestion { --anno-issue-color: var(--tblr-info); }
+
+/* ══════════════════════════════════════════
+   九、译文评估标注
+   PaliContentService::injectAnnotationNotes 注入:句尾的 span.anno-assessment-mark(对勾图标),
+   颜色按分数档位(AnnotationLayout::assessmentLevel):high(≥80)绿、medium(≥60)橙、low 红;
+   同段有句子评估过时,没评估的句子是灰色对勾(anno-assessment-none,不能点)。
+   点击由 issue-popover.js 弹出分数、方法与理由,弹层条目复用 .anno-issue-item。
+   ══════════════════════════════════════════ */
+
+.anno-assessment-high   { --anno-assessment-color: var(--tblr-success); }
+.anno-assessment-medium { --anno-assessment-color: var(--tblr-warning); }
+.anno-assessment-low    { --anno-assessment-color: var(--tblr-danger); }
+/* 同段有句子评估过时,没评估的句子补的灰色对勾:只标句子边界,不能点 */
+.anno-assessment-none   { --anno-assessment-color: var(--tblr-gray-400, #adb5bd); }
+
+article.reader-body span.anno-assessment-mark {
+    cursor: pointer;
+    margin-left: 0.15em;
+    color: var(--anno-assessment-color);
+    font-size: 0.95em;
+    vertical-align: -0.05em;
+}
+article.reader-body span.anno-assessment-mark:hover,
+article.reader-body span.anno-assessment-mark.is-active {
+    filter: brightness(0.85);
+}
+article.reader-body span.anno-assessment-mark.anno-assessment-none {
+    cursor: default;
+    filter: none;
+}
+
+.anno-assessment-item .anno-issue-item-head .ti {
+    color: var(--anno-assessment-color);
+}
+.anno-assessment-score {
+    color: var(--anno-assessment-color);
+    font-variant-numeric: tabular-nums;
+}

+ 67 - 17
api-v13/resources/js/modules/issue-popover.js

@@ -1,15 +1,17 @@
 // resources/js/modules/issue-popover.js
 //
-// 阅读页 issue 标注的点击查看。正文里的标注由后端注入
+// 阅读页 issue 与译文评估标注的点击查看。正文里的标注由后端注入
 // (PaliContentService::injectAnnotationNotes → AnnotationLayout):
-//   span.anno-issue       有位置的 issue,包住被指出的原文(交叠的已合并成一个 span)
-//   span.anno-issue-mark  没有位置的 issue,句尾的三角叹号图标
-// 两者都带 data-discussion-ids="id1,id2"。点击后逐条取 GET /api/v3/discussions/{id}
-// (issue 对匿名公开),桌面端用 Bootstrap Popover,手机端用 #annoIssueDrawer 抽屉。
+//   span.anno-issue            有位置的 issue,包住被指出的原文(交叠的已合并成一个 span)
+//   span.anno-issue-mark       没有位置的 issue,句尾的三角叹号图标
+//   span.anno-assessment-mark  译文评估,句尾的对勾图标,颜色按分数档位(high / medium / low)
+// 都带 data-discussion-ids="id1,id2"。点击后逐条取 GET /api/v3/discussions/{id}
+// (issue 与评估对匿名公开),桌面端用 Bootstrap Popover,手机端用 #annoIssueDrawer 抽屉。
 
 import * as bootstrap from 'bootstrap';
 
 const isMobile = () => window.innerWidth < 768;
+const MARKERS = '.anno-issue, .anno-issue-mark, .anno-assessment-mark';
 const cache = new Map();
 
 function escapeHtml(text) {
@@ -48,11 +50,48 @@ async function fetchIssue(id) {
     }
 }
 
-function renderIssue(issue, i18n) {
+// level 取自标注的 data-level(后端 AnnotationLayout::assessmentLevel 定档),前端不重复分档阈值
+function renderAssessment(assessment, i18n, level) {
+    const score = Number(assessment.score ?? 0).toString();
+    const editor = assessment.editor?.nickName ?? '';
+    const coEditor = assessment.co_editor?.nickName ?? '';
+    const date = assessment.updated_at
+        ? new Date(assessment.updated_at).toLocaleDateString()
+        : '';
+    const replies = assessment.reply_count
+        ? i18n.replies.replace(':count', assessment.reply_count)
+        : '';
+    const body = (assessment.summary ?? assessment.content ?? '').trim();
+    const meta = [
+        i18n.method.replace(':method', assessment.severity ?? ''),
+        coEditor ? `${editor} + ${coEditor}` : editor,
+        date,
+        replies,
+    ]
+        .filter(Boolean)
+        .join(' · ');
+
+    return `
+        <div class="anno-issue-item anno-assessment-item anno-assessment-${level}">
+            <div class="anno-issue-item-head">
+                <i class="ti ti-circle-check"></i>
+                <span class="anno-assessment-score">${escapeHtml(score)}</span>
+                <span>${escapeHtml(assessment.title ?? '')}</span>
+            </div>
+            ${body ? `<div class="anno-issue-item-body">${escapeHtml(body)}</div>` : ''}
+            <div class="anno-issue-item-meta">${escapeHtml(meta)}</div>
+        </div>`;
+}
+
+function renderIssue(issue, i18n, level) {
     if (!issue) {
         return `<div class="anno-issue-item text-muted">${escapeHtml(i18n.notFound)}</div>`;
     }
 
+    if (issue.type === 'assessment') {
+        return renderAssessment(issue, i18n, level ?? 'none');
+    }
+
     const severity = issue.severity ?? '';
     const severityLabel = i18n.severity?.[severity] ?? '';
     const editor = issue.editor?.nickName ?? '';
@@ -79,13 +118,13 @@ function renderIssue(issue, i18n) {
         </div>`;
 }
 
-async function renderList(ids, i18n) {
+async function renderList(ids, i18n, level) {
     const results = await Promise.allSettled(ids.map(fetchIssue));
 
     return results
         .map((r) =>
             r.status === 'fulfilled'
-                ? renderIssue(r.value, i18n)
+                ? renderIssue(r.value, i18n, level)
                 : `<div class="anno-issue-item text-danger">${escapeHtml(i18n.failed)}</div>`,
         )
         .join('');
@@ -95,7 +134,7 @@ export function initIssueMarkers() {
     const article = document.querySelector('article.reader-body');
     const drawerEl = document.getElementById('annoIssueDrawer');
 
-    if (!article || !article.querySelector('.anno-issue, .anno-issue-mark')) {
+    if (!article || !article.querySelector(MARKERS)) {
         return;
     }
 
@@ -105,6 +144,8 @@ export function initIssueMarkers() {
         notFound: '',
         replies: ':count',
         severity: {},
+        assessmentTitle: '',
+        method: ':method',
     };
 
     try {
@@ -140,8 +181,17 @@ export function initIssueMarkers() {
 
         activeEl = el;
         el.classList.add('is-active');
+        const title = el.classList.contains('anno-assessment-mark')
+            ? i18n.assessmentTitle
+            : i18n.title;
 
         if (isMobile() && drawerEl) {
+            const heading = drawerEl.querySelector('.offcanvas-title');
+
+            if (heading) {
+                heading.textContent = title;
+            }
+
             const body = drawerEl.querySelector('.offcanvas-body');
             body.innerHTML = loadingHtml;
             const drawer = bootstrap.Offcanvas.getOrCreateInstance(drawerEl);
@@ -149,7 +199,7 @@ export function initIssueMarkers() {
                 once: true,
             });
             drawer.show();
-            body.innerHTML = await renderList(ids, i18n);
+            body.innerHTML = await renderList(ids, i18n, el.dataset.level);
 
             return;
         }
@@ -161,12 +211,12 @@ export function initIssueMarkers() {
             placement: 'bottom',
             fallbackPlacements: ['top', 'bottom'],
             customClass: 'anno-issue-popover',
-            title: escapeHtml(i18n.title),
+            title: escapeHtml(title),
             content: `<div class="anno-issue-list">${loadingHtml}</div>`,
         });
         activePopover = popover;
         popover.show();
-        const html = await renderList(ids, i18n);
+        const html = await renderList(ids, i18n, el.dataset.level);
 
         if (activePopover !== popover) {
             return; // 等待期间已经切到别的标注
@@ -195,7 +245,7 @@ export function initIssueMarkers() {
             return;
         }
 
-        const el = e.target.closest('.anno-issue, .anno-issue-mark');
+        const el = e.target.closest(MARKERS);
 
         if (!el) {
             return;
@@ -206,7 +256,9 @@ export function initIssueMarkers() {
     });
 
     article.addEventListener('keydown', (e) => {
-        const el = e.target.closest?.('.anno-issue-mark');
+        const el = e.target.closest?.(
+            '.anno-issue-mark, .anno-assessment-mark',
+        );
 
         if (el && (e.key === 'Enter' || e.key === ' ')) {
             e.preventDefault();
@@ -218,9 +270,7 @@ export function initIssueMarkers() {
     document.addEventListener('click', (e) => {
         if (
             activePopover &&
-            !e.target.closest(
-                '.anno-issue, .anno-issue-mark, .anno-issue-popover',
-            )
+            !e.target.closest(`${MARKERS}, .anno-issue-popover`)
         ) {
             deactivate();
         }

+ 4 - 0
api-v13/resources/lang/en/library.php

@@ -175,4 +175,8 @@ return [
     'issue_severity_error' => 'Error',
     'issue_severity_warning' => 'Warning',
     'issue_severity_suggestion' => 'Suggestion',
+
+    // reader: 译文评估标注(reader.js 弹出层)
+    'assessment_title' => 'Assessment',
+    'assessment_method' => 'Method: :method',
 ];

+ 4 - 0
api-v13/resources/lang/zh-Hans/library.php

@@ -178,4 +178,8 @@ return [
     'issue_severity_error' => '错误',
     'issue_severity_warning' => '警告',
     'issue_severity_suggestion' => '建议',
+
+    // reader: 译文评估标注(reader.js 弹出层)
+    'assessment_title' => '译文评估',
+    'assessment_method' => '方法::method',
 ];

+ 3 - 1
api-v13/resources/views/library/book/read.blade.php

@@ -18,7 +18,7 @@
 {{-- 术语抽屉 --}}
 <x-wiki.term-drawer />
 
-{{-- issue 抽屉(手机端):点击正文里的 issue 高亮 / 句尾图标时由 reader.js 填充;
+{{-- issue / 评估抽屉(手机端):点击正文里的 issue 高亮、句尾三角或评估对勾时由 reader.js 填充;
      桌面端用 popover,文案同样取自 data-i18n --}}
 <div class="offcanvas offcanvas-bottom"
     tabindex="-1"
@@ -35,6 +35,8 @@
             'warning' => __('library.issue_severity_warning'),
             'suggestion' => __('library.issue_severity_suggestion'),
         ],
+        'assessmentTitle' => __('library.assessment_title'),
+        'method' => __('library.assessment_method'),
     ], JSON_UNESCAPED_UNICODE) }}">
     <div class="offcanvas-header">
         <h5 class="offcanvas-title" id="annoIssueDrawerLabel">{{ __('library.issue_title') }}</h5>