Sfoglia il codice sorgente

feat(reader): 阅读页 issue 标注样式,点击弹出 issue 内容(桌面 popover / 手机抽屉)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
visuddhinanda 15 ore fa
parent
commit
294ea0a373

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

@@ -427,3 +427,76 @@ article.reader-body .centered {
 article.reader-body .centered .para-block {
     margin-bottom: 1.5em;
 }
+
+/* ══════════════════════════════════════════
+   八、issue 标注
+   PaliContentService::injectAnnotationNotes 注入:有位置的 issue 是 span.anno-issue(高亮原文),
+   没有位置的是句尾的 span.anno-issue-mark(三角叹号图标)。颜色按 severity:
+   error 红、warning 橙、suggestion 蓝,没有 severity 的灰。点击由 reader.js 弹出内容。
+   与 .evaluate-*(整块底色)区分:这里用波浪下划线 + 淡底色。
+   ══════════════════════════════════════════ */
+
+article.reader-body .anno-issue-error      { --anno-issue-color: var(--tblr-danger); }
+article.reader-body .anno-issue-warning    { --anno-issue-color: var(--tblr-warning); }
+article.reader-body .anno-issue-suggestion { --anno-issue-color: var(--tblr-info); }
+article.reader-body .anno-issue-none       { --anno-issue-color: var(--tblr-secondary); }
+
+article.reader-body span.anno-issue {
+    cursor: pointer;
+    text-decoration: underline wavy var(--anno-issue-color);
+    text-decoration-skip-ink: none;
+    text-underline-offset: 0.25em;
+    background-color: color-mix(in srgb, var(--anno-issue-color) 12%, transparent);
+    border-radius: 2px;
+}
+article.reader-body span.anno-issue:hover,
+article.reader-body span.anno-issue.is-active {
+    background-color: color-mix(in srgb, var(--anno-issue-color) 26%, transparent);
+}
+
+article.reader-body span.anno-issue-mark {
+    cursor: pointer;
+    margin-left: 0.15em;
+    color: var(--anno-issue-color);
+    font-size: 0.95em;
+    vertical-align: -0.05em;
+}
+article.reader-body span.anno-issue-mark:hover,
+article.reader-body span.anno-issue-mark.is-active {
+    filter: brightness(0.85);
+}
+
+/* 弹出层:桌面 popover 与手机抽屉共用的条目样式 */
+.anno-issue-list {
+    max-height: 60vh;
+    overflow-y: auto;
+}
+.anno-issue-item + .anno-issue-item {
+    margin-top: 0.75rem;
+    padding-top: 0.75rem;
+    border-top: 1px solid var(--tblr-border-color);
+}
+.anno-issue-item-head {
+    display: flex;
+    align-items: center;
+    gap: 0.4rem;
+    font-weight: 600;
+}
+.anno-issue-item-head .ti {
+    color: var(--anno-issue-color, var(--tblr-secondary));
+}
+.anno-issue-item-body {
+    margin-top: 0.25rem;
+    white-space: pre-line;
+}
+.anno-issue-item-meta {
+    margin-top: 0.25rem;
+    font-size: 0.8125rem;
+    color: var(--tblr-secondary);
+}
+.anno-issue-popover {
+    --bs-popover-max-width: 22rem;
+}
+.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); }

+ 228 - 0
api-v13/resources/js/modules/issue-popover.js

@@ -0,0 +1,228 @@
+// resources/js/modules/issue-popover.js
+//
+// 阅读页 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 抽屉。
+
+import * as bootstrap from 'bootstrap';
+
+const isMobile = () => window.innerWidth < 768;
+const cache = new Map();
+
+function escapeHtml(text) {
+    const div = document.createElement('div');
+    div.textContent = text ?? '';
+
+    return div.innerHTML;
+}
+
+async function fetchIssue(id) {
+    if (!cache.has(id)) {
+        cache.set(
+            id,
+            fetch(`/api/v3/discussions/${encodeURIComponent(id)}`, {
+                headers: { Accept: 'application/json' },
+            }).then(async (res) => {
+                if (res.status === 404) {
+                    return null;
+                }
+
+                if (!res.ok) {
+                    throw new Error(String(res.status));
+                }
+
+                return (await res.json()).data;
+            }),
+        );
+    }
+
+    try {
+        return await cache.get(id);
+    } catch (e) {
+        cache.delete(id); // 失败的不缓存,下次点击重试
+
+        throw e;
+    }
+}
+
+function renderIssue(issue, i18n) {
+    if (!issue) {
+        return `<div class="anno-issue-item text-muted">${escapeHtml(i18n.notFound)}</div>`;
+    }
+
+    const severity = issue.severity ?? '';
+    const severityLabel = i18n.severity?.[severity] ?? '';
+    const editor = issue.editor?.nickName ?? '';
+    const date = issue.created_at
+        ? new Date(issue.created_at).toLocaleDateString()
+        : '';
+    const replies = issue.reply_count
+        ? i18n.replies.replace(':count', issue.reply_count)
+        : '';
+    // summary 是后端渲染好的纯文本(模板已展开),比原始 markdown 更适合直接显示
+    const body = (issue.summary ?? issue.content ?? '').trim();
+    const meta = [severityLabel, editor, date, replies]
+        .filter(Boolean)
+        .join(' · ');
+
+    return `
+        <div class="anno-issue-item anno-issue-${escapeHtml(severity || 'none')}">
+            <div class="anno-issue-item-head">
+                <i class="ti ti-alert-triangle"></i>
+                <span>${escapeHtml(issue.title ?? '')}</span>
+            </div>
+            ${body ? `<div class="anno-issue-item-body">${escapeHtml(body)}</div>` : ''}
+            <div class="anno-issue-item-meta">${escapeHtml(meta)}</div>
+        </div>`;
+}
+
+async function renderList(ids, i18n) {
+    const results = await Promise.allSettled(ids.map(fetchIssue));
+
+    return results
+        .map((r) =>
+            r.status === 'fulfilled'
+                ? renderIssue(r.value, i18n)
+                : `<div class="anno-issue-item text-danger">${escapeHtml(i18n.failed)}</div>`,
+        )
+        .join('');
+}
+
+export function initIssueMarkers() {
+    const article = document.querySelector('article.reader-body');
+    const drawerEl = document.getElementById('annoIssueDrawer');
+
+    if (!article || !article.querySelector('.anno-issue, .anno-issue-mark')) {
+        return;
+    }
+
+    let i18n = {
+        loading: '…',
+        failed: '!',
+        notFound: '',
+        replies: ':count',
+        severity: {},
+    };
+
+    try {
+        i18n = { ...i18n, ...JSON.parse(drawerEl?.dataset.i18n ?? '{}') };
+    } catch {
+        // 文案解析失败就用兜底文案
+    }
+
+    const loadingHtml = `<div class="text-muted small">${escapeHtml(i18n.loading)}</div>`;
+
+    let activeEl = null;
+    let activePopover = null;
+    const deactivate = () => {
+        activePopover?.dispose();
+        activePopover = null;
+        activeEl?.classList.remove('is-active');
+        activeEl = null;
+    };
+
+    const open = async (el) => {
+        const ids = (el.dataset.discussionIds ?? '').split(',').filter(Boolean);
+
+        if (ids.length === 0) {
+            return;
+        }
+
+        const same = activeEl === el;
+        deactivate();
+
+        if (same && !isMobile()) {
+            return; // 再点一次同一个标注:收起
+        }
+
+        activeEl = el;
+        el.classList.add('is-active');
+
+        if (isMobile() && drawerEl) {
+            const body = drawerEl.querySelector('.offcanvas-body');
+            body.innerHTML = loadingHtml;
+            const drawer = bootstrap.Offcanvas.getOrCreateInstance(drawerEl);
+            drawerEl.addEventListener('hidden.bs.offcanvas', deactivate, {
+                once: true,
+            });
+            drawer.show();
+            body.innerHTML = await renderList(ids, i18n);
+
+            return;
+        }
+
+        const popover = new bootstrap.Popover(el, {
+            trigger: 'manual',
+            html: true,
+            sanitize: false, // 内容全部经过 escapeHtml
+            placement: 'bottom',
+            fallbackPlacements: ['top', 'bottom'],
+            customClass: 'anno-issue-popover',
+            title: escapeHtml(i18n.title),
+            content: `<div class="anno-issue-list">${loadingHtml}</div>`,
+        });
+        activePopover = popover;
+        popover.show();
+        const html = await renderList(ids, i18n);
+
+        if (activePopover !== popover) {
+            return; // 等待期间已经切到别的标注
+        }
+
+        // 不用 setContent():它在 manual 触发下会重走一遍 show(),完成回调里因 _isHovered === false
+        // 直接把 popover 收起(Bootstrap 5.3)。与 term-tooltip.js 一样直接改 body,再重新定位。
+        const body = `<div class="anno-issue-list">${html}</div>`;
+        popover._config.content = body;
+        const tip = document.getElementById(
+            el.getAttribute('aria-describedby') ?? '',
+        );
+        tip?.querySelector('.popover-body')?.replaceChildren(
+            document.createRange().createContextualFragment(body),
+        );
+        popover.update();
+    };
+
+    article.addEventListener('click', (e) => {
+        // 标注里可能嵌着边注(label / input / .sidenote)与术语,它们有自己的点击行为
+        if (
+            e.target.closest(
+                'label, input, .sidenote, .marginnote, .term-ref, a',
+            )
+        ) {
+            return;
+        }
+
+        const el = e.target.closest('.anno-issue, .anno-issue-mark');
+
+        if (!el) {
+            return;
+        }
+
+        e.preventDefault();
+        open(el);
+    });
+
+    article.addEventListener('keydown', (e) => {
+        const el = e.target.closest?.('.anno-issue-mark');
+
+        if (el && (e.key === 'Enter' || e.key === ' ')) {
+            e.preventDefault();
+            open(el);
+        }
+    });
+
+    // 点标注与弹层以外的地方收起 popover
+    document.addEventListener('click', (e) => {
+        if (
+            activePopover &&
+            !e.target.closest(
+                '.anno-issue, .anno-issue-mark, .anno-issue-popover',
+            )
+        ) {
+            deactivate();
+        }
+    });
+}

+ 3 - 0
api-v13/resources/js/modules/reader.js

@@ -1,10 +1,13 @@
 // resources/js/modules/reader.js
 
+import { initIssueMarkers } from './issue-popover.js';
+
 export function initReader() {
     applyAnnotationMode();
     numberAnnotations();
     injectCommentaryMarkers();
     injectEvaluateMarkers();
+    initIssueMarkers();
     initTocToggle();
     initHashScroll();
     initAnnotationMode();

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

@@ -165,4 +165,14 @@ return [
     'course_no_history' => 'No past courses',
     'course_history_title' => 'Past Courses',
     'course_history_subtitle' => 'Completed public courses — revisit past study groups',
+
+    // reader: issue 标注(reader.js 弹出层)
+    'issue_title' => 'Issues',
+    'issue_loading' => 'Loading…',
+    'issue_load_failed' => 'Failed to load',
+    'issue_not_found' => 'This issue no longer exists',
+    'issue_replies' => ':count replies',
+    'issue_severity_error' => 'Error',
+    'issue_severity_warning' => 'Warning',
+    'issue_severity_suggestion' => 'Suggestion',
 ];

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

@@ -168,4 +168,14 @@ return [
     'course_no_history' => '暂无历史课程',
     'course_history_title' => '历史课程',
     'course_history_subtitle' => '已结束的公开课程,回顾往期研读',
+
+    // reader: issue 标注(reader.js 弹出层)
+    'issue_title' => '问题',
+    'issue_loading' => '加载中…',
+    'issue_load_failed' => '加载失败',
+    'issue_not_found' => '这条问题已不存在',
+    'issue_replies' => ':count 条回复',
+    'issue_severity_error' => '错误',
+    'issue_severity_warning' => '警告',
+    'issue_severity_suggestion' => '建议',
 ];

+ 25 - 0
api-v13/resources/views/library/book/read.blade.php

@@ -18,6 +18,31 @@
 {{-- 术语抽屉 --}}
 <x-wiki.term-drawer />
 
+{{-- issue 抽屉(手机端):点击正文里的 issue 高亮 / 句尾图标时由 reader.js 填充;
+     桌面端用 popover,文案同样取自 data-i18n --}}
+<div class="offcanvas offcanvas-bottom"
+    tabindex="-1"
+    id="annoIssueDrawer"
+    aria-labelledby="annoIssueDrawerLabel"
+    data-i18n="{{ json_encode([
+        'title' => __('library.issue_title'),
+        'loading' => __('library.issue_loading'),
+        'failed' => __('library.issue_load_failed'),
+        'notFound' => __('library.issue_not_found'),
+        'replies' => __('library.issue_replies'),
+        'severity' => [
+            'error' => __('library.issue_severity_error'),
+            'warning' => __('library.issue_severity_warning'),
+            'suggestion' => __('library.issue_severity_suggestion'),
+        ],
+    ], JSON_UNESCAPED_UNICODE) }}">
+    <div class="offcanvas-header">
+        <h5 class="offcanvas-title" id="annoIssueDrawerLabel">{{ __('library.issue_title') }}</h5>
+        <button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="Close"></button>
+    </div>
+    <div class="offcanvas-body anno-issue-list"></div>
+</div>
+
 {{-- Navbar --}}
 <header class="navbar navbar-expand-md navbar-light d-print-none">
     <div class="container-xl">