Merge pull request #2254 from pikasTech/fix/2245-mdtodo-dedupe-jd01

fix: dedupe MDTODO page context labels
This commit is contained in:
Lyon
2026-06-29 20:49:46 +08:00
committed by GitHub
5 changed files with 38 additions and 51 deletions
@@ -112,8 +112,6 @@ const reportPaneOpen = computed(() => Boolean(report.reportPreview.value));
const workbenchLaunchEnabled = computed(() => launch.isLaunchEnabled(source.navigation.value)); const workbenchLaunchEnabled = computed(() => launch.isLaunchEnabled(source.navigation.value));
const launchButtonDisabled = computed(() => !selectedTask.value || !workbenchLaunchEnabled.value || launch.launchLoading.value); const launchButtonDisabled = computed(() => !selectedTask.value || !workbenchLaunchEnabled.value || launch.launchLoading.value);
const selectedSourceCanReindex = computed(() => Boolean(selectedSourceId.value && !taskData.taskLoading.value && !source.sourceReindexLoading.value)); 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[] { function reportLinksOnly(links: MdtodoTaskLinkRecord[]): MdtodoTaskLinkRecord[] {
return links.filter((link) => link.kind === "markdown-report"); return links.filter((link) => link.kind === "markdown-report");
} }
@@ -398,17 +396,6 @@ function setError(err: unknown): void {
<template> <template>
<section class="route-stack mdtodo-page" data-testid="project-management-mdtodo"> <section class="route-stack mdtodo-page" data-testid="project-management-mdtodo">
<header class="mdtodo-framebar">
<div class="mdtodo-title-lockup">
<span>Project Management</span>
<strong>MDTODO</strong>
</div>
<div class="mdtodo-route-chip">
<span>{{ selectedFileName }}</span>
<small v-if="selectedTask">{{ selectedTask.taskId }} · {{ selectedTask.title }}</small>
</div>
</header>
<LoadingState v-if="loading && !source.sources.value.length" class="mdtodo-route-loading" label="加载 MDTODO source" /> <LoadingState v-if="loading && !source.sources.value.length" class="mdtodo-route-loading" label="加载 MDTODO source" />
<section v-else-if="error" class="data-panel mdtodo-error" data-testid="project-management-error"> <section v-else-if="error" class="data-panel mdtodo-error" data-testid="project-management-error">
<EmptyState title="MDTODO 加载失败" :description="error" :api-error="apiError" :diagnostic="diagnostic" action-label="重试" @action="loadPage" /> <EmptyState title="MDTODO 加载失败" :description="error" :api-error="apiError" :diagnostic="diagnostic" action-label="重试" @action="loadPage" />
@@ -422,8 +409,6 @@ function setError(err: unknown): void {
:selected-file-ref="selectedFileRef" :selected-file-ref="selectedFileRef"
:file-loading="taskData.taskLoading.value" :file-loading="taskData.taskLoading.value"
:reindex-loading="source.sourceReindexLoading.value" :reindex-loading="source.sourceReindexLoading.value"
:selected-file-name="selectedFileName"
:selected-task-label="selectedTaskLabel"
@update:selected-source-id="selectedSourceId = $event" @update:selected-source-id="selectedSourceId = $event"
@update:selected-file-ref="selectFile($event)" @update:selected-file-ref="selectFile($event)"
@open-source-config="openSourceConfig" @open-source-config="openSourceConfig"
@@ -577,25 +562,13 @@ function setError(err: unknown): void {
</template> </template>
<style scoped> <style scoped>
.mdtodo-page { display: grid; height: calc(100dvh - 68px); max-height: calc(100dvh - 68px); min-width: 0; min-height: 0; align-content: stretch; grid-template-rows: auto auto auto minmax(0, 1fr); gap: 0; overflow: hidden; border: 1px solid #cad6dd; border-radius: 8px; background: #f7fafb; } .mdtodo-page { display: grid; height: calc(100dvh - 68px); max-height: calc(100dvh - 68px); min-width: 0; min-height: 0; align-content: stretch; grid-template-rows: 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; }
.mdtodo-route-chip { display: inline-flex; min-width: 0; align-items: center; justify-content: flex-end; gap: 8px; color: #52616b; font-size: 12px; font-weight: 750; }
.mdtodo-route-chip span,
.mdtodo-route-chip small { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.mdtodo-route-chip small { max-width: min(46vw, 720px); color: #0f766e; font: inherit; }
.mdtodo-route-loading, .mdtodo-route-loading,
.mdtodo-error { grid-row: 2 / -1; min-width: 0; min-height: 0; height: 100%; align-self: stretch; } .mdtodo-error { grid-row: 1 / -1; min-width: 0; min-height: 0; height: 100%; align-self: stretch; }
.mdtodo-notices { display: grid; grid-row: 3; min-width: 0; min-height: 0; gap: 0; } .mdtodo-notices { display: grid; grid-row: 2; min-width: 0; min-height: 0; gap: 0; }
.source-message, .source-error { margin: 0; border-radius: 6px; padding: 6px 10px; font-size: 13px; } .source-message, .source-error { margin: 0; border-radius: 6px; padding: 6px 10px; font-size: 13px; }
.source-message { border: 1px solid #bbf7d0; background: #f0fdf4; color: #166534; } .source-message { border: 1px solid #bbf7d0; background: #f0fdf4; color: #166534; }
.source-error { border: 1px solid #fecaca; background: #fef2f2; color: #991b1b; } .source-error { border: 1px solid #fecaca; background: #fef2f2; color: #991b1b; }
.mdtodo-workspace { grid-row: 4; min-height: 0; height: 100%; max-height: none; align-self: stretch; } .mdtodo-workspace { grid-row: 3; min-height: 0; height: 100%; max-height: none; align-self: stretch; }
.mdtodo-error { display: grid; align-content: start; gap: 12px; padding: 12px; overflow: auto; } .mdtodo-error { display: grid; align-content: start; gap: 12px; padding: 12px; overflow: auto; }
@media (max-width: 720px) {
.mdtodo-framebar { align-items: start; flex-direction: column; gap: 2px; height: auto; padding: 6px 10px; }
.mdtodo-route-chip { width: 100%; justify-content: flex-start; }
}
</style> </style>
@@ -2,11 +2,12 @@
<!-- Responsibility: main workspace task title, status, body (the primary content), inline edit, report links, Workbench launch. --> <!-- Responsibility: main workspace task title, status, body (the primary content), inline edit, report links, Workbench launch. -->
<script setup lang="ts"> <script setup lang="ts">
import { computed } from "vue";
import type { MdtodoTaskDetailRecord, MdtodoTaskLinkRecord, MdtodoTaskRecord, ProjectNavigationResponse } from "@/api"; import type { MdtodoTaskDetailRecord, MdtodoTaskLinkRecord, MdtodoTaskRecord, ProjectNavigationResponse } from "@/api";
import LoadingState from "@/components/common/LoadingState.vue"; import LoadingState from "@/components/common/LoadingState.vue";
import EmptyState from "@/components/common/EmptyState.vue"; import EmptyState from "@/components/common/EmptyState.vue";
defineProps<{ const props = defineProps<{
task: MdtodoTaskRecord | null; task: MdtodoTaskRecord | null;
detail: MdtodoTaskDetailRecord | null; detail: MdtodoTaskDetailRecord | null;
detailLoading: boolean; detailLoading: boolean;
@@ -44,6 +45,18 @@ const emit = defineEmits<{
openReport: [link: MdtodoTaskLinkRecord]; openReport: [link: MdtodoTaskLinkRecord];
launchWorkbench: []; launchWorkbench: [];
}>(); }>();
const bodyDiffersFromTitle = computed(() => {
const body = normalizeTaskText(props.bodyText);
if (!body) return false;
const title = normalizeTaskText(props.task?.title);
const titleWithId = normalizeTaskText([props.task?.taskId, props.task?.title].filter(Boolean).join(" "));
return body !== title && body !== titleWithId;
});
function normalizeTaskText(value?: string | null): string {
return String(value ?? "").replace(/\s+/g, " ").trim();
}
</script> </script>
<template> <template>
@@ -96,8 +109,8 @@ const emit = defineEmits<{
</div> </div>
</div> </div>
<article v-else class="markdown-body task-body-rendered" data-testid="mdtodo-body-rendered" @dblclick="emit('beginBodyEdit')"> <article v-else class="markdown-body task-body-rendered" data-testid="mdtodo-body-rendered" @dblclick="emit('beginBodyEdit')">
<div v-if="bodyHtml" v-html="bodyHtml" /> <div v-if="bodyHtml && bodyDiffersFromTitle" v-html="bodyHtml" />
<p v-else class="empty-inline">暂无正文</p> <p v-else class="empty-inline">暂无补充正文</p>
</article> </article>
</section> </section>
</section> </section>
@@ -12,8 +12,6 @@ defineProps<{
selectedFileRef: string | null; selectedFileRef: string | null;
fileLoading: boolean; fileLoading: boolean;
reindexLoading: boolean; reindexLoading: boolean;
selectedFileName: string;
selectedTaskLabel: string;
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
@@ -47,10 +45,6 @@ function onFileSelect(event: Event): void {
<option v-for="file in files" :key="file.fileRef" :value="file.fileRef">{{ fileDisplayName(file) }}</option> <option v-for="file in files" :key="file.fileRef" :value="file.fileRef">{{ fileDisplayName(file) }}</option>
</select> </select>
</label> </label>
<div class="toolbar-context" data-testid="mdtodo-toolbar-context">
<span class="toolbar-file-name" :title="selectedFileName">{{ selectedFileName }}</span>
<small v-if="selectedTaskLabel" class="toolbar-task-label">{{ selectedTaskLabel }}</small>
</div>
<div class="toolbar-actions"> <div class="toolbar-actions">
<button class="btn btn-secondary toolbar-create" type="button" data-testid="mdtodo-new-task-open" @click="emit('openCreateTask')">+ 任务</button> <button class="btn btn-secondary toolbar-create" type="button" data-testid="mdtodo-new-task-open" @click="emit('openCreateTask')">+ 任务</button>
<button class="icon-button" type="button" data-testid="mdtodo-source-config-open" aria-label="配置 Source" @click="emit('openSourceConfig')"></button> <button class="icon-button" type="button" data-testid="mdtodo-source-config-open" aria-label="配置 Source" @click="emit('openSourceConfig')"></button>
@@ -61,20 +55,16 @@ function onFileSelect(event: Event): void {
</template> </template>
<style scoped> <style scoped>
.mdtodo-toolbar { display: grid; grid-template-columns: minmax(160px, 240px) minmax(220px, 360px) minmax(0, 1fr) auto; gap: 8px; align-items: center; border-bottom: 1px solid #d8e1e7; background: #ffffff; padding: 7px 10px; } .mdtodo-toolbar { display: grid; grid-template-columns: minmax(160px, 240px) minmax(260px, 1fr) auto; gap: 8px; align-items: center; border-bottom: 1px solid #d8e1e7; background: #ffffff; padding: 7px 10px; }
.toolbar-field { display: grid; min-width: 0; grid-template-columns: auto minmax(0, 1fr); gap: 7px; align-items: center; } .toolbar-field { display: grid; min-width: 0; grid-template-columns: auto minmax(0, 1fr); gap: 7px; align-items: center; }
.toolbar-field span { color: #5d6a73; font-size: 10px; font-weight: 850; letter-spacing: 0; text-transform: uppercase; } .toolbar-field span { color: #5d6a73; font-size: 10px; font-weight: 850; letter-spacing: 0; text-transform: uppercase; }
.toolbar-field select { width: 100%; min-width: 0; height: 32px; border: 1px solid #cbd6dd; border-radius: 6px; background: #fff; color: #111827; padding: 0 9px; font: inherit; font-size: 13px; } .toolbar-field select { width: 100%; min-width: 0; height: 32px; border: 1px solid #cbd6dd; border-radius: 6px; background: #fff; color: #111827; padding: 0 9px; font: inherit; font-size: 13px; }
.toolbar-context { display: grid; min-width: 0; gap: 1px; align-content: center; border-left: 1px solid #e2e8ed; padding-left: 10px; }
.toolbar-file-name { min-width: 0; overflow: hidden; color: #111827; font-size: 12px; font-weight: 800; text-overflow: ellipsis; white-space: nowrap; }
.toolbar-task-label { color: #0f766e; font-size: 11px; font-weight: 750; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.toolbar-actions { display: flex; gap: 6px; justify-content: flex-end; align-items: center; } .toolbar-actions { display: flex; gap: 6px; justify-content: flex-end; align-items: center; }
.toolbar-create { min-height: 32px; border-color: #a7c2c0; color: #0f4f4a; } .toolbar-create { min-height: 32px; border-color: #a7c2c0; color: #0f4f4a; }
.icon-button { display: inline-grid; width: 32px; height: 32px; place-items: center; border: 1px solid #cbd6dd; border-radius: 6px; background: #fff; color: #111827; font-weight: 850; } .icon-button { display: inline-grid; width: 32px; height: 32px; place-items: center; border: 1px solid #cbd6dd; border-radius: 6px; background: #fff; color: #111827; font-weight: 850; }
.icon-button:disabled { opacity: 0.5; } .icon-button:disabled { opacity: 0.5; }
@media (max-width: 980px) { @media (max-width: 980px) {
.mdtodo-toolbar { grid-template-columns: 1fr 1fr auto; } .mdtodo-toolbar { grid-template-columns: 1fr 1fr auto; }
.toolbar-context { display: none; }
} }
@media (max-width: 640px) { @media (max-width: 640px) {
.mdtodo-toolbar { grid-template-columns: 1fr; } .mdtodo-toolbar { grid-template-columns: 1fr; }
+9 -2
View File
@@ -26,7 +26,7 @@ let installed = false;
let flushTimer: number | null = null; let flushTimer: number | null = null;
export function installWebRum(): void { export function installWebRum(): void {
if (installed || typeof window === "undefined") return; if (installed || !hasBrowserRumRuntime() || typeof window.addEventListener !== "function") return;
installed = true; installed = true;
window.addEventListener("load", () => window.setTimeout(recordNavigationTiming, 0), { once: true }); window.addEventListener("load", () => window.setTimeout(recordNavigationTiming, 0), { once: true });
window.addEventListener("visibilitychange", () => { window.addEventListener("visibilitychange", () => {
@@ -36,7 +36,7 @@ export function installWebRum(): void {
} }
export function recordApiTiming(input: ApiTimingInput): void { export function recordApiTiming(input: ApiTimingInput): void {
if (typeof window === "undefined") return; if (!hasBrowserRumRuntime()) return;
if (isWorkbenchPageRoute()) return; if (isWorkbenchPageRoute()) return;
const valueMs = Math.max(0, Date.now() - input.startedAt); const valueMs = Math.max(0, Date.now() - input.startedAt);
enqueue({ enqueue({
@@ -96,6 +96,7 @@ function enqueue(event: RumEvent): void {
} }
function scheduleFlush(): void { function scheduleFlush(): void {
if (!hasBrowserRumRuntime()) return;
if (flushTimer !== null) return; if (flushTimer !== null) return;
flushTimer = window.setTimeout(() => flushRum(false), FLUSH_INTERVAL_MS); flushTimer = window.setTimeout(() => flushRum(false), FLUSH_INTERVAL_MS);
} }
@@ -116,10 +117,16 @@ function flushRum(useBeacon: boolean): void {
} }
function pageRoute(): string { function pageRoute(): string {
if (typeof window === "undefined" || !window.location) return "/";
return `${window.location.pathname}${window.location.hash || ""}` || "/"; return `${window.location.pathname}${window.location.hash || ""}` || "/";
} }
function hasBrowserRumRuntime(): boolean {
return typeof window !== "undefined" && typeof window.setTimeout === "function" && typeof window.clearTimeout === "function";
}
function isWorkbenchPageRoute(): boolean { function isWorkbenchPageRoute(): boolean {
if (typeof window === "undefined" || !window.location) return false;
const pathname = window.location.pathname || ""; const pathname = window.location.pathname || "";
return pathname.startsWith("/workbench") || pathname.startsWith("/workspace"); return pathname.startsWith("/workbench") || pathname.startsWith("/workspace");
} }
@@ -506,7 +506,7 @@ export function resetWorkbenchPerformanceForTest(): void {
openJourney = null; openJourney = null;
queue = []; queue = [];
uiTrace = null; uiTrace = null;
if (flushTimer !== null && typeof window !== "undefined") window.clearTimeout(flushTimer); if (flushTimer !== null && hasBrowserTimerRuntime()) window.clearTimeout(flushTimer);
flushTimer = null; flushTimer = null;
installed = false; installed = false;
} }
@@ -542,12 +542,12 @@ function enqueue(event: WorkbenchPerformanceEvent): void {
} }
function scheduleFlush(): void { function scheduleFlush(): void {
if (typeof window === "undefined" || flushTimer !== null) return; if (!hasBrowserTimerRuntime() || flushTimer !== null) return;
flushTimer = window.setTimeout(() => flushWorkbenchPerformance(false), FLUSH_INTERVAL_MS); flushTimer = window.setTimeout(() => flushWorkbenchPerformance(false), FLUSH_INTERVAL_MS);
} }
function flushWorkbenchPerformance(useBeacon: boolean): void { function flushWorkbenchPerformance(useBeacon: boolean): void {
if (flushTimer !== null && typeof window !== "undefined") { if (flushTimer !== null && hasBrowserTimerRuntime()) {
window.clearTimeout(flushTimer); window.clearTimeout(flushTimer);
flushTimer = null; flushTimer = null;
} }
@@ -686,10 +686,14 @@ function timestampMs(value: unknown): number | null {
} }
function pageRoute(): string { function pageRoute(): string {
if (typeof window === "undefined") return "/workbench"; if (typeof window === "undefined" || !window.location) return "/workbench";
return routeTemplate(`${window.location.pathname}${window.location.hash || ""}` || "/workbench"); return routeTemplate(`${window.location.pathname}${window.location.hash || ""}` || "/workbench");
} }
function hasBrowserTimerRuntime(): boolean {
return typeof window !== "undefined" && typeof window.setTimeout === "function" && typeof window.clearTimeout === "function";
}
function routeTemplate(input: string): string { function routeTemplate(input: string): string {
const raw = safeText(input) || "/workbench"; const raw = safeText(input) || "/workbench";
const pathOnly = raw.startsWith("#") ? raw.slice(1) || "/" : raw.split(/[?#]/u, 1)[0] || "/"; const pathOnly = raw.startsWith("#") ? raw.slice(1) || "/" : raw.split(/[?#]/u, 1)[0] || "/";