fix(mdtodo): restore projected report access on web

This commit is contained in:
root
2026-06-27 02:28:16 +00:00
parent efb21d7b0b
commit 6825e1e2cb
6 changed files with 87 additions and 16 deletions
@@ -27,6 +27,9 @@ Root notes with [context](https://example.test/context).
expect(document.tasks[0].depth).toBe(0);
expect(document.tasks[1].depth).toBe(1);
expect(document.tasks[0].linkCount).toBe(1);
expect(document.tasks[0].links).toEqual([
expect.objectContaining({ label: "context", kind: "external", relativePath: null })
]);
expect(document.tasks[0].bodyPreview).toBe("Root notes with [context](https://example.test/context).");
expect(document.tasks[0].body).toBeUndefined();
expect(document.tasks[0].rawContent).toBeUndefined();
+11 -9
View File
@@ -10,7 +10,6 @@ import path from "node:path";
const taskLinePattern = /^(\s*)(?:[-*+]|\d+[.)])\s+\[([ xX-])\]\s+(.*)$/u;
const rxxHeadingPattern = /^(#{2,})\s+(R\d+(?:\.\d+)*)(?:\s+(.*?))?\s*$/iu;
const rxxIdPattern = /^R\d+(?:\.\d+)*$/iu;
const markdownLinkPattern = /!?\[[^\]]*\]\([^)]+\)|https?:\/\/\S+/giu;
const statusAliases = new Map([
["open", "open"],
@@ -220,6 +219,7 @@ function parseRxxTasks(lines, context) {
if (parentRxxId && !parentTaskRef) diagnostics.push({ code: "missing_parent_rxx_id", rxxId: block.rxxId, parentRxxId, lineNumber: block.lineNumber });
const bodyText = lines.slice(block.lineIndex + 1, block.endLine).join("\n");
const bodyPreview = bodyPreviewFromLines(lines, block.lineIndex + 1, block.endLine);
const links = markdownLinksFromBody(bodyText, context.relativePath);
const title = cleanTaskTitle(block.title || bodyPreview || block.rxxId);
return {
taskRef: taskRefForRxx(context.sourceId, context.fileRef, block.rxxId),
@@ -236,12 +236,13 @@ function parseRxxTasks(lines, context) {
depth: rxxDepth(block.rxxId),
lineNumber: block.lineNumber,
ordinal: index + 1,
linkCount: countMarkdownLinks(bodyText),
linkCount: links.length,
sourceFingerprint: context.fingerprint,
parserMode: "rxx",
headingLevel: block.hashes.length,
statusMarker: block.statusMarker,
bodyPreview
bodyPreview,
links
};
});
@@ -535,15 +536,14 @@ function stripStatusMarkers(value) {
return String(value ?? "").replace(/\s*\[([^\]]+)\]/gu, (match, label) => normalizeStatus(label) ? "" : match).trim();
}
function countMarkdownLinks(value) {
return [...String(value ?? "").matchAll(markdownLinkPattern)].length;
}
function markdownLinksFromBody(body, relativePath) {
const text = String(body ?? "");
const links = [];
const markdownPattern = /!?\[([^\]]*)\]\(([^)]+)\)/gu;
const markdownRanges = [];
let ordinal = 0;
for (const match of String(body ?? "").matchAll(markdownPattern)) {
for (const match of text.matchAll(markdownPattern)) {
markdownRanges.push([match.index ?? 0, (match.index ?? 0) + String(match[0] ?? "").length]);
ordinal += 1;
const label = String(match[1] ?? "").trim();
const href = cleanMarkdownHref(match[2]);
@@ -559,7 +559,9 @@ function markdownLinksFromBody(body, relativePath) {
});
}
for (const match of String(body ?? "").matchAll(/https?:\/\/\S+/giu)) {
for (const match of text.matchAll(/https?:\/\/\S+/giu)) {
const index = match.index ?? 0;
if (markdownRanges.some(([start, end]) => index >= start && index < end)) continue;
ordinal += 1;
const href = String(match[0] ?? "").replace(/[),.;]+$/u, "");
links.push({
+3 -1
View File
@@ -469,7 +469,9 @@ function appendTaskFilterClauses(params, clauses, filters = {}) {
}
function taskFromRow(row) {
const taskJson = row.task_json && typeof row.task_json === "object" && !Array.isArray(row.task_json) ? row.task_json : {};
return {
...taskJson,
taskRef: row.task_ref,
projectId: row.project_id,
sourceId: row.source_id,
@@ -484,7 +486,7 @@ function taskFromRow(row) {
linkCount: row.link_count,
sourceFingerprint: row.source_fingerprint,
updatedAt: row.updated_at,
task: row.task_json ?? {}
task: taskJson
};
}
@@ -114,6 +114,14 @@ export interface MdtodoTaskRecord {
sourceFingerprint?: string;
bodyPreview?: string;
parserMode?: string;
links?: MdtodoTaskLinkRecord[];
task?: {
bodyPreview?: string | null;
links?: MdtodoTaskLinkRecord[];
linkCount?: number | null;
title?: string | null;
[key: string]: unknown;
};
updatedAt?: string;
}
@@ -124,6 +132,8 @@ export interface MdtodoTaskLinkRecord {
kind?: string;
relativePath?: string | null;
ordinal?: number;
availability?: string;
message?: string;
valuesRedacted?: boolean;
}
@@ -4,7 +4,7 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { projectManagementAPI, type MdtodoTaskMutationInput, type MdtodoTaskRecord, type ProjectNavigationResponse, type ProjectSource } from "@/api";
import { projectManagementAPI, type MdtodoTaskLinkRecord, type MdtodoTaskMutationInput, type MdtodoTaskRecord, type ProjectNavigationResponse, type ProjectSource } from "@/api";
import type { ApiError, ErrorDiagnostic } from "@/types";
import EmptyState from "@/components/common/EmptyState.vue";
import LoadingState from "@/components/common/LoadingState.vue";
@@ -74,6 +74,7 @@ const deleteConfirm = ref(false);
const selectedSource = computed(() => source.sources.value.find((s) => s.sourceId === selectedSourceId.value) ?? null);
const selectedFile = computed(() => taskData.files.value.find((f) => f.fileRef === selectedFileRef.value) ?? null);
const selectedTask = computed(() => taskData.tasks.value.find((t) => t.taskRef === selectedTaskRef.value) ?? null);
const selectedTaskProjection = computed(() => selectedTask.value?.task ?? null);
const fileScopedTasks = computed(() => selectedFileRef.value ? taskData.tasks.value.filter((t) => t.fileRef === selectedFileRef.value) : taskData.tasks.value);
const filteredTasks = computed(() => {
@@ -92,8 +93,18 @@ const taskStatusCounts = computed(() => fileScopedTasks.value.reduce((acc, task)
return acc;
}, {} as Record<string, number>));
const selectedTaskBody = computed(() => taskData.taskDetail.value?.body ?? selectedTask.value?.bodyPreview ?? "");
const selectedTaskLinks = computed(() => taskData.taskDetail.value?.links ?? []);
const selectedTaskBody = computed(() => taskData.taskDetail.value?.body ?? selectedTask.value?.bodyPreview ?? selectedTaskProjection.value?.bodyPreview ?? "");
const selectedTaskLinks = computed(() => {
const detailLinks = taskData.taskDetail.value?.links ?? [];
const detailReportLinks = reportLinksOnly(detailLinks);
if (detailReportLinks.length || detailLinks.length) return detailReportLinks;
const projectedLinks = selectedTask.value?.links ?? selectedTaskProjection.value?.links ?? [];
const projectedReportLinks = reportLinksOnly(projectedLinks);
if (projectedReportLinks.length || projectedLinks.length) return projectedReportLinks;
const linkCount = Number(selectedTask.value?.linkCount ?? selectedTaskProjection.value?.linkCount ?? 0);
if (selectedTask.value && linkCount > 0) return [projectedReportLink(selectedTask.value, linkCount)];
return [];
});
const selectedTaskBodyHtml = computed(() => report.markdownToHtml(selectedTaskBody.value));
const reportPreviewHtml = computed(() => report.markdownToHtml(report.reportPreview.value?.content ?? ""));
const selectedFileName = computed(() => selectedFile.value ? fileDisplayName(selectedFile.value) : selectedTask.value?.fileRef || "-");
@@ -103,6 +114,25 @@ const launchButtonDisabled = computed(() => !selectedTask.value || !workbenchLau
const selectedSourceCanReindex = computed(() => Boolean(selectedSourceId.value && !taskData.taskLoading.value && !source.sourceReindexLoading.value));
const selectedTaskLabel = computed(() => selectedTask.value ? `${selectedTask.value.taskId || ''} ${selectedTask.value.title || ''}`.trim() : "");
function reportLinksOnly(links: MdtodoTaskLinkRecord[]): MdtodoTaskLinkRecord[] {
return links.filter((link) => link.kind === "markdown-report");
}
function projectedReportLink(task: MdtodoTaskRecord, linkCount: number): MdtodoTaskLinkRecord {
const taskId = task.taskId || task.rxxId || "report";
return {
linkId: `projected-report-unavailable-${task.taskRef}`,
label: linkCount === 1 ? `${taskId} 报告` : `${taskId} 报告 (${linkCount})`,
href: "",
kind: "markdown-report",
relativePath: null,
ordinal: 1,
availability: "projected-link-unavailable",
message: "任务投影确认存在报告链接,但当前任务详情读取失败或旧索引缺少链接目标;恢复 HWPOD 详情读取或重建索引后可打开完整报告。",
valuesRedacted: true
};
}
onMounted(() => void loadPage());
watch(selectedSourceId, async (sourceId) => {
@@ -545,8 +575,8 @@ function setError(err: unknown): void {
</template>
<style scoped>
.mdtodo-page { display: grid; height: calc(100dvh - 68px); max-height: calc(100dvh - 68px); min-width: 0; min-height: 0; grid-template-rows: 34px auto auto minmax(0, 1fr); gap: 0; overflow: hidden; border: 1px solid #cad6dd; border-radius: 8px; background: #f7fafb; }
.mdtodo-framebar { display: flex; min-width: 0; align-items: center; justify-content: space-between; gap: 12px; border-bottom: 1px solid #d8e1e7; background: #f9fbfc; padding: 0 10px; }
.mdtodo-page { display: grid; height: calc(100dvh - 68px); max-height: calc(100dvh - 68px); min-width: 0; min-height: 0; grid-template-rows: auto auto auto minmax(0, 1fr); gap: 0; overflow: hidden; border: 1px solid #cad6dd; border-radius: 8px; background: #f7fafb; }
.mdtodo-framebar { display: flex; min-width: 0; min-height: 34px; align-items: center; justify-content: space-between; gap: 12px; border-bottom: 1px solid #d8e1e7; background: #f9fbfc; padding: 4px 10px; }
.mdtodo-title-lockup { display: inline-flex; min-width: 0; align-items: baseline; gap: 8px; }
.mdtodo-title-lockup span { color: #6b7a86; font-size: 10px; font-weight: 850; letter-spacing: 0; text-transform: uppercase; }
.mdtodo-title-lockup strong { color: #111827; font-size: 16px; line-height: 1; }
@@ -25,8 +25,33 @@ export function useMdtodoReportPreview() {
return DOMPurify.sanitize(marked.parse(text, { async: false }) as string);
}
function projectedLinkNeedsRefresh(link: MdtodoTaskLinkRecord): boolean {
return link.availability === "projected-link-unavailable";
}
function openProjectedLinkPlaceholder(link: MdtodoTaskLinkRecord): MdtodoReportPreviewRecord {
const message = link.message || "任务投影确认存在报告链接,但当前缓存缺少报告目标;恢复 HWPOD 详情读取或重建 MDTODO 索引后可打开完整报告。";
const content = `## 报告索引待刷新\n\n${message}\n\n- 报告: ${link.label || link.linkId}\n- 状态: projection-only\n`;
const report: MdtodoReportPreviewRecord = {
content,
fingerprint: `projected:${link.linkId}`,
revision: `projected:${link.linkId}`,
byteCount: content.length,
render: "markdown",
linkId: link.linkId,
label: link.label,
href: link.href,
valuesRedacted: true
};
reportPreview.value = report;
activeReportLink.value = link;
reportError.value = null;
return report;
}
async function openReportPreview(taskRef: string, link: MdtodoTaskLinkRecord): Promise<MdtodoReportPreviewRecord | null> {
if (!link.linkId || link.kind !== "markdown-report") return null;
if (projectedLinkNeedsRefresh(link)) return openProjectedLinkPlaceholder(link);
reportLoading.value = true;
reportError.value = null;
try {
@@ -83,4 +108,3 @@ export function useMdtodoReportPreview() {
closeFullscreen
};
}