Merge pull request #2217 from pikasTech/fix/2216-mdtodo-web-rewrite

fix(web): rewrite MDTODO page into bounded decomposed workspace (PJ2026-01040409) #2216
This commit is contained in:
Lyon
2026-06-27 03:36:55 +08:00
committed by GitHub
16 changed files with 1955 additions and 1120 deletions
@@ -0,0 +1,54 @@
<!-- SPEC: PJ2026-01040409 MDTODO前端重写; draft-2026-06-26-p0-mdtodo-web-rewrite. -->
<!-- Responsibility: new root/subtask/continue task dialog never a resident create box on the main layout. -->
<script setup lang="ts">
defineProps<{
show: boolean;
newTitle: string;
newBody: string;
loading: boolean;
error: string | null;
hasSelectedTask: boolean;
}>();
const emit = defineEmits<{
close: [];
"update:newTitle": [value: string];
"update:newBody": [value: string];
create: [kind: "root" | "subtask" | "continue"];
}>();
</script>
<template>
<div v-if="show" class="modal-backdrop" data-testid="mdtodo-task-create-dialog" role="dialog" aria-modal="true" aria-label="新建 MDTODO 任务" @click.self="emit('close')">
<section class="mdtodo-dialog task-create-dialog">
<header><h2>新建任务</h2><button class="icon-button" type="button" aria-label="关闭新建任务" @click="emit('close')">×</button></header>
<div class="task-create-box">
<label><span>New task</span><input :value="newTitle" data-testid="mdtodo-new-title" :disabled="loading" placeholder="新任务标题" @input="emit('update:newTitle', ($event.target as HTMLInputElement).value)" /></label>
<textarea :value="newBody" data-testid="mdtodo-new-body" :disabled="loading" rows="5" placeholder="新任务正文,可留空。" @input="emit('update:newBody', ($event.target as HTMLTextAreaElement).value)" />
<div class="editor-actions">
<button class="btn btn-secondary" type="button" data-testid="mdtodo-add-root" :disabled="loading" @click="emit('create', 'root')">新增根任务</button>
<button class="btn btn-secondary" type="button" data-testid="mdtodo-add-subtask" :disabled="loading || !hasSelectedTask" @click="emit('create', 'subtask')">新增子任务</button>
<button class="btn btn-secondary" type="button" data-testid="mdtodo-continue-task" :disabled="loading || !hasSelectedTask" @click="emit('create', 'continue')">延续同级</button>
</div>
<p v-if="error" class="source-error" data-testid="mdtodo-task-mutation-error">{{ error }}</p>
</div>
</section>
</div>
</template>
<style scoped>
.modal-backdrop { position: fixed; z-index: 60; inset: 0; display: grid; place-items: start center; overflow: auto; background: rgba(15, 23, 42, 0.38); padding: 8dvh 16px 32px; }
.mdtodo-dialog { display: grid; width: min(720px, calc(100vw - 32px)); gap: 14px; border: 1px solid #cbd8d2; border-radius: 8px; background: #fff; color: #14211d; padding: 16px; box-shadow: 0 24px 60px rgba(15, 23, 42, 0.22); }
.mdtodo-dialog header { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.mdtodo-dialog h2 { margin: 0; font-size: 18px; }
.icon-button { display: inline-grid; width: 36px; height: 36px; place-items: center; border: 1px solid #cbd8d2; border-radius: 6px; background: #fff; color: #14211d; font-weight: 850; }
.task-create-dialog { width: min(620px, calc(100vw - 32px)); }
.task-create-box { display: grid; gap: 8px; border-top: 1px solid #e2ece8; padding-top: 10px; }
.task-create-box label { display: grid; min-width: 0; gap: 5px; color: #52615c; font-size: 12px; font-weight: 800; }
.task-create-box input, .task-create-box textarea { width: 100%; min-width: 0; border: 1px solid #cbd8d4; border-radius: 6px; background: #fff; color: #14211d; font: inherit; line-height: 1.4; padding: 8px 9px; }
.task-create-box textarea { resize: vertical; }
.editor-actions { display: flex; flex-wrap: wrap; gap: 8px; }
.source-error { margin: 0; border: 1px solid #fecaca; background: #fef2f2; color: #991b1b; border-radius: 6px; padding: 8px 10px; font-size: 13px; }
</style>
@@ -0,0 +1,80 @@
<!-- SPEC: PJ2026-01040409 MDTODO前端重写; draft-2026-06-26-p0-mdtodo-web-rewrite. -->
<!-- Responsibility: collapse Source/TaskRef/File/Updated/counts/diagnostics/link metadata into an info dialog. -->
<script setup lang="ts">
import type { MdtodoFileRecord, MdtodoTaskRecord, MdtodoTaskPage, ProjectSource, ProjectWorkbenchLinkRecord, ProjectNavigationResponse } from "@/api";
import { displayDate, shortRef } from "@/composables/mdtodo/useMdtodoDisplay";
defineProps<{
show: boolean;
sources: ProjectSource[];
selectedSource: ProjectSource | null;
selectedSourceId: string | null;
selectedFile: MdtodoFileRecord | null;
selectedFileRef: string | null;
selectedFileName: string;
selectedTask: MdtodoTaskRecord | null;
links: ProjectWorkbenchLinkRecord[];
taskPage: MdtodoTaskPage | null;
fileScopedTaskCount: number;
taskStatusCounts: Record<string, number>;
navigation: ProjectNavigationResponse | null;
}>();
const emit = defineEmits<{ close: [] }>();
function workbenchLaunchEnabledLabel(navigation: ProjectNavigationResponse | null): string {
return navigation?.navigation?.capabilities?.workbenchLaunch === true ? "launch enabled" : "launch unavailable";
}
</script>
<template>
<div v-if="show" class="modal-backdrop" data-testid="mdtodo-info-popover" role="dialog" aria-modal="true" aria-label="MDTODO 摘要" @click.self="emit('close')">
<section class="mdtodo-dialog mdtodo-info-dialog">
<header><h2>MDTODO 摘要</h2><button class="icon-button" type="button" aria-label="关闭摘要" @click="emit('close')">×</button></header>
<dl class="summary-list">
<div><dt>Source</dt><dd>{{ sources.length }} / {{ selectedSource?.displayName || selectedSourceId || '-' }}</dd></div>
<div><dt>Source ID</dt><dd><code>{{ selectedSourceId || '-' }}</code></dd></div>
<div><dt>Source Status</dt><dd>{{ selectedSource?.status || '-' }}</dd></div>
<div><dt>File</dt><dd>{{ selectedFile?.relativePath || selectedFileName }}</dd></div>
<div><dt>File Ref</dt><dd><code>{{ selectedFileRef || '-' }}</code></dd></div>
<div><dt>File Fingerprint</dt><dd><code>{{ shortRef(selectedFile?.fingerprint) }}</code></dd></div>
<div><dt>Task</dt><dd>{{ selectedTask?.taskId || '-' }} {{ selectedTask?.title || '' }}</dd></div>
<div><dt>TaskRef</dt><dd><code>{{ shortRef(selectedTask?.taskRef) }}</code></dd></div>
<div><dt>Status</dt><dd>{{ selectedTask?.status || '-' }}</dd></div>
<div><dt>Updated</dt><dd>{{ displayDate(selectedTask?.updatedAt) }}</dd></div>
<div><dt>Tasks</dt><dd>待办 {{ taskStatusCounts.open || 0 }} / 进行中 {{ taskStatusCounts.in_progress || 0 }} / 完成 {{ taskStatusCounts.done || 0 }} / 阻塞 {{ taskStatusCounts.blocked || 0 }}<span v-if="taskPage?.hasMore"> · window {{ taskPage.limit }}</span></dd></div>
<div><dt>File Tasks</dt><dd>{{ fileScopedTaskCount }}<span v-if="taskPage?.total != null"> / {{ taskPage.total }} total</span></dd></div>
<div><dt>Workbench Links</dt><dd>{{ links.length }} / {{ workbenchLaunchEnabledLabel(navigation) }}</dd></div>
<div v-if="links.length" class="info-links">
<dt>Link Sessions</dt>
<dd>
<ul class="info-link-list">
<li v-for="link in links.slice(0, 8)" :key="link.linkId">
<strong>{{ link.sessionId || link.linkId }}</strong>
<small>{{ shortRef(link.traceId || link.taskRef || link.projectId) }}</small>
</li>
</ul>
</dd>
</div>
</dl>
</section>
</div>
</template>
<style scoped>
.modal-backdrop { position: fixed; z-index: 60; inset: 0; display: grid; place-items: start center; overflow: auto; background: rgba(15, 23, 42, 0.38); padding: 8dvh 16px 32px; }
.mdtodo-dialog { display: grid; width: min(720px, calc(100vw - 32px)); gap: 14px; border: 1px solid #cbd8d2; border-radius: 8px; background: #fff; color: #14211d; padding: 16px; box-shadow: 0 24px 60px rgba(15, 23, 42, 0.22); }
.mdtodo-dialog header { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.mdtodo-dialog h2 { margin: 0; font-size: 18px; }
.icon-button { display: inline-grid; width: 36px; height: 36px; place-items: center; border: 1px solid #cbd8d2; border-radius: 6px; background: #fff; color: #14211d; font-weight: 850; }
.summary-list { display: grid; gap: 8px; margin: 0; }
.summary-list > div { display: grid; gap: 4px; border-bottom: 1px solid #e2e8f0; padding-bottom: 8px; }
.summary-list dt { color: #64706b; font-size: 11px; font-weight: 800; text-transform: uppercase; }
.summary-list dd { min-width: 0; margin: 0; overflow-wrap: anywhere; color: #14211d; font-size: 13px; }
.summary-list code { color: #0f766e; font-size: 12px; }
.info-link-list { display: grid; gap: 6px; margin: 0; padding: 0; list-style: none; }
.info-link-list li { display: grid; gap: 3px; border: 1px solid #d8e2df; border-radius: 6px; padding: 8px; }
.info-link-list small { color: #64706b; overflow-wrap: anywhere; }
</style>
@@ -0,0 +1,555 @@
<!-- SPEC: PJ2026-01040409 MDTODO前端重写; draft-2026-06-26-p0-mdtodo-web-rewrite. -->
<!-- Responsibility: MDTODO page shell route restore, loading/error boundary, bounded three-pane workspace assembly. -->
<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 type { ApiError, ErrorDiagnostic } from "@/types";
import EmptyState from "@/components/common/EmptyState.vue";
import LoadingState from "@/components/common/LoadingState.vue";
import SplitWorkspaceLayout from "@/components/layout/SplitWorkspaceLayout.vue";
import MdtodoToolbar from "@/components/mdtodo/MdtodoToolbar.vue";
import MdtodoTaskTree from "@/components/mdtodo/MdtodoTaskTree.vue";
import MdtodoTaskPanel from "@/components/mdtodo/MdtodoTaskPanel.vue";
import MdtodoReportPanel from "@/components/mdtodo/MdtodoReportPanel.vue";
import MdtodoSourceDialog from "@/components/mdtodo/MdtodoSourceDialog.vue";
import MdtodoCreateTaskDialog from "@/components/mdtodo/MdtodoCreateTaskDialog.vue";
import MdtodoInfoDialog from "@/components/mdtodo/MdtodoInfoDialog.vue";
import { useMdtodoRouteSelection } from "@/composables/mdtodo/useMdtodoRouteSelection";
import { useMdtodoSource } from "@/composables/mdtodo/useMdtodoSource";
import { useMdtodoTaskData } from "@/composables/mdtodo/useMdtodoTaskData";
import { useMdtodoTaskMutation } from "@/composables/mdtodo/useMdtodoTaskMutation";
import { useMdtodoReportPreview } from "@/composables/mdtodo/useMdtodoReportPreview";
import { useMdtodoWorkbenchLaunch } from "@/composables/mdtodo/useMdtodoWorkbenchLaunch";
import { fileDisplayName } from "@/composables/mdtodo/useMdtodoDisplay";
const router = useRouter();
const route = useRoute();
// Page-level loading / error boundary
const loading = ref(false);
const error = ref<string | null>(null);
const apiError = ref<ApiError | null>(null);
const diagnostic = ref<ErrorDiagnostic | null>(null);
// Composables
const selection = useMdtodoRouteSelection({ router, route });
const source = useMdtodoSource();
const taskData = useMdtodoTaskData();
const mutation = useMdtodoTaskMutation();
const report = useMdtodoReportPreview();
const launch = useMdtodoWorkbenchLaunch(router);
// Selection state (URL is authority; these mirror the URL)
const selectedSourceId = ref<string | null>(null);
const selectedFileRef = ref<string | null>(null);
const selectedTaskRef = ref<string | null>(null);
// Tree / pane view state (local, not URL-authoritative)
const taskSearch = ref("");
const taskStatusFilter = ref("all");
const collapsedTaskRefs = ref<Set<string>>(new Set());
const taskPaneCollapsed = ref(false);
const taskPaneWidth = ref(30);
const reportPaneCollapsed = ref(false);
const reportPaneWidth = ref(50);
// Dialog visibility
const showInfo = ref(false);
const showSourceConfig = ref(false);
const showTaskCreateDialog = ref(false);
// Inline edit state
const editingTitle = ref(false);
const editingBody = ref(false);
const editTitle = ref("");
const editStatus = ref("open");
const editBody = ref("");
const newTaskTitle = ref("");
const newTaskBody = ref("");
const deleteConfirm = ref(false);
// Derived selections
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 fileScopedTasks = computed(() => selectedFileRef.value ? taskData.tasks.value.filter((t) => t.fileRef === selectedFileRef.value) : taskData.tasks.value);
const filteredTasks = computed(() => {
const query = taskSearch.value.trim().toLowerCase();
return fileScopedTasks.value.filter((task) => {
const statusOk = taskStatusFilter.value === "all" || (task.status || "unknown") === taskStatusFilter.value;
if (!statusOk) return false;
if (!query) return true;
return [task.taskId, task.title, task.taskRef].some((value) => String(value ?? "").toLowerCase().includes(query));
});
});
const taskStatusCounts = computed(() => fileScopedTasks.value.reduce((acc, task) => {
const key = task.status || "unknown";
acc[key] = (acc[key] ?? 0) + 1;
return acc;
}, {} as Record<string, number>));
const selectedTaskBody = computed(() => taskData.taskDetail.value?.body ?? selectedTask.value?.bodyPreview ?? "");
const selectedTaskLinks = computed(() => taskData.taskDetail.value?.links ?? []);
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 || "-");
const reportPaneOpen = computed(() => Boolean(report.reportPreview.value));
const workbenchLaunchEnabled = computed(() => launch.isLaunchEnabled(source.navigation.value));
const launchButtonDisabled = computed(() => !selectedTask.value || !workbenchLaunchEnabled.value || launch.launchLoading.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() : "");
onMounted(() => void loadPage());
watch(selectedSourceId, async (sourceId) => {
if (!sourceId || loading.value || selection.applyingRouteSelection.value) return;
await loadFilesAndTasks(sourceId, { updateRoute: true });
});
watch(selectedTaskRef, async (taskRef) => {
await Promise.all([taskData.loadLinks(taskRef), taskData.loadTaskDetail(taskRef)]);
});
watch(() => route.fullPath, async () => {
if (loading.value || !source.sources.value.length) return;
await applyRouteSelection();
});
watch(selectedTask, (task) => resetTaskEditor(task), { immediate: true });
watch(taskData.taskDetail, (detail) => {
if (detail?.taskRef === selectedTaskRef.value && !editingBody.value) editBody.value = detail.body || "";
});
async function loadPage(): Promise<void> {
loading.value = true;
error.value = null;
apiError.value = null;
diagnostic.value = null;
try {
const { sources } = await source.loadNavigationAndSources();
const sourceFromRoute = selection.readSelection().sourceId;
selectedSourceId.value = sourceFromRoute && sources.some((s) => s.sourceId === sourceFromRoute)
? sourceFromRoute
: selectedSourceId.value && sources.some((s) => s.sourceId === selectedSourceId.value)
? selectedSourceId.value
: sources[0]?.sourceId ?? null;
if (selectedSourceId.value) await loadFilesAndTasks(selectedSourceId.value, selection.routeSelectionOptions(true));
else await taskData.loadLinks(null);
} catch (err) {
setError(err);
} finally {
loading.value = false;
}
}
async function loadFilesAndTasks(sourceId: string, options: { fileRef?: string | null; taskId?: string | null; reportLinkId?: string | null; updateRoute?: boolean } = {}): Promise<void> {
taskData.taskLoading.value = true;
try {
await taskData.loadFiles(sourceId);
selectedFileRef.value = options.fileRef && taskData.files.value.some((f) => f.fileRef === options.fileRef)
? options.fileRef
: selectedFileRef.value && taskData.files.value.some((f) => f.fileRef === selectedFileRef.value)
? selectedFileRef.value
: taskData.files.value[0]?.fileRef ?? null;
await taskData.loadTaskWindow(sourceId, selectedFileRef.value, options.taskId ?? selectedTaskRef.value);
const preferred = taskData.resolvePreferredTask(options.taskId ?? selectedTaskRef.value);
selectedTaskRef.value = preferred?.taskRef ?? taskData.tasks.value[0]?.taskRef ?? null;
collapsedTaskRefs.value = new Set();
if (!selectedTaskRef.value) await Promise.all([taskData.loadLinks(null), taskData.loadTaskDetail(null)]);
if (options.reportLinkId && selectedTaskRef.value) {
await Promise.all([taskData.loadLinks(selectedTaskRef.value), taskData.loadTaskDetail(selectedTaskRef.value)]);
await report.openReportPreviewById(selectedTaskRef.value, selectedTaskLinks.value, options.reportLinkId);
} else if (options.updateRoute !== false) {
void syncRoute({ replace: true });
}
} catch (err) {
setError(err);
} finally {
taskData.taskLoading.value = false;
}
}
async function selectFile(fileRef: string): Promise<void> {
selectedFileRef.value = fileRef || null;
report.closeReportPreview();
if (!selectedSourceId.value) return;
taskData.taskLoading.value = true;
try {
await taskData.loadTaskWindow(selectedSourceId.value, selectedFileRef.value, null);
selectedTaskRef.value = taskData.tasks.value[0]?.taskRef ?? null;
collapsedTaskRefs.value = new Set();
if (!selectedTaskRef.value) await Promise.all([taskData.loadLinks(null), taskData.loadTaskDetail(null)]);
void syncRoute({ replace: false });
} catch (err) {
setError(err);
} finally {
taskData.taskLoading.value = false;
}
}
function selectTask(taskRef: string): void {
selectedTaskRef.value = taskRef;
report.closeReportPreview();
void syncRoute({ replace: false });
}
async function syncRoute(options: { replace?: boolean; reportLinkId?: string | null } = {}): Promise<void> {
await selection.syncSelectionRoute({
sourceId: selectedSourceId.value,
fileRef: selectedFileRef.value,
taskId: selectedTask.value?.taskId ?? null,
reportLinkId: options.reportLinkId ?? report.activeReportLink.value?.linkId ?? null,
replace: options.replace
});
}
async function applyRouteSelection(): Promise<void> {
const sel = selection.readSelection();
const nextSourceId = sel.sourceId && source.sources.value.some((s) => s.sourceId === sel.sourceId) ? sel.sourceId : selectedSourceId.value;
if (!nextSourceId) return;
selection.applyingRouteSelection.value = true;
try {
selectedSourceId.value = nextSourceId;
await loadFilesAndTasks(nextSourceId, selection.routeSelectionOptions(false));
} finally {
selection.applyingRouteSelection.value = false;
}
}
function resetTaskEditor(task: MdtodoTaskRecord | null): void {
editingTitle.value = false;
editingBody.value = false;
editTitle.value = task?.title || "";
editStatus.value = task?.status || "open";
editBody.value = taskData.taskDetail.value?.taskRef === task?.taskRef ? taskData.taskDetail.value?.body || "" : "";
deleteConfirm.value = false;
}
function taskFingerprint(): string | undefined {
return selectedTask.value?.sourceFingerprint || selectedFile.value?.fingerprint || undefined;
}
async function reloadAfterMutation(): Promise<void> {
const sourceId = selectedSourceId.value;
if (!sourceId) return;
await taskData.loadFiles(sourceId);
await taskData.loadTaskWindow(sourceId, selectedFileRef.value, selectedTaskRef.value);
}
async function saveTaskBasics(): Promise<void> {
const task = selectedTask.value;
if (!task) return;
const input: MdtodoTaskMutationInput = { expectedFingerprint: taskFingerprint() };
if (editTitle.value.trim() && editTitle.value.trim() !== (task.title || "")) input.title = editTitle.value.trim();
if (editStatus.value && editStatus.value !== (task.status || "open")) input.status = editStatus.value;
if (!input.title && !input.status) {
mutation.taskMutationMessage.value = "任务标题和状态没有变化";
mutation.taskMutationError.value = null;
return;
}
const nextRef = await mutation.updateTask(task.taskRef, input, "任务已保存", reloadAfterMutation);
if (nextRef && !mutation.taskMutationError.value) {
if (taskData.tasks.value.some((t) => t.taskRef === nextRef)) selectedTaskRef.value = nextRef;
editingTitle.value = false;
}
}
async function saveTaskBody(): Promise<void> {
const task = selectedTask.value;
if (!task) return;
const nextRef = await mutation.updateTask(task.taskRef, { expectedFingerprint: taskFingerprint(), body: editBody.value }, "正文已保存", reloadAfterMutation);
if (nextRef && !mutation.taskMutationError.value) {
if (taskData.tasks.value.some((t) => t.taskRef === nextRef)) selectedTaskRef.value = nextRef;
editingBody.value = false;
}
}
async function createTask(kind: "root" | "subtask" | "continue"): Promise<void> {
const title = newTaskTitle.value.trim();
if (!title) {
mutation.taskMutationError.value = "新任务标题不能为空";
mutation.taskMutationMessage.value = null;
return;
}
const task = selectedTask.value;
const input: MdtodoTaskMutationInput = {
sourceId: selectedSourceId.value || undefined,
fileRef: selectedFileRef.value || undefined,
expectedFingerprint: taskFingerprint(),
title,
body: newTaskBody.value
};
if (kind === "subtask" && task) input.parentTaskRef = task.taskRef;
if (kind === "continue" && task) input.afterTaskRef = task.taskRef;
const nextRef = await mutation.createTask(input, "任务已创建", reloadAfterMutation);
if (nextRef && !mutation.taskMutationError.value) {
newTaskTitle.value = "";
newTaskBody.value = "";
showTaskCreateDialog.value = false;
if (taskData.tasks.value.some((t) => t.taskRef === nextRef)) selectedTaskRef.value = nextRef;
}
}
async function deleteSelectedTask(): Promise<void> {
const task = selectedTask.value;
if (!task) return;
if (!deleteConfirm.value) {
deleteConfirm.value = true;
mutation.taskMutationMessage.value = "再次点击确认删除当前任务及子任务";
mutation.taskMutationError.value = null;
return;
}
await mutation.deleteTask(task.taskRef, { expectedFingerprint: taskFingerprint() }, reloadAfterMutation);
deleteConfirm.value = false;
if (!mutation.taskMutationError.value) selectedTaskRef.value = taskData.tasks.value[0]?.taskRef ?? null;
}
function openReport(link: typeof selectedTaskLinks.value[number]): void {
const taskRef = selectedTaskRef.value;
if (!taskRef) return;
void report.openReportPreview(taskRef, link).then((r) => {
if (r) { reportPaneCollapsed.value = false; void syncRoute({ replace: false }); }
});
}
function closeReport(): void {
report.closeReportPreview();
void syncRoute({ replace: false });
}
async function launchWorkbench(): Promise<void> {
const task = selectedTask.value;
if (!task || launchButtonDisabled.value) return;
const result = await launch.launchTask(task, selectedTaskBody.value, selectedTaskLinks.value, selectedFileName.value, source.navigation.value);
if (result) {
await taskData.loadLinks(task.taskRef);
await launch.navigateToWorkbench(result);
}
}
function openSourceConfig(): void {
source.openSourceForm(selectedSource.value);
showSourceConfig.value = true;
}
async function saveSourceConfig(): Promise<void> {
const savedId = await source.saveSourceConfig();
if (savedId) {
showSourceConfig.value = false;
if (savedId !== selectedSourceId.value) selectedSourceId.value = savedId;
}
}
async function reindexSource(): Promise<void> {
const sourceId = selectedSourceId.value;
if (!sourceId) return;
const result = await source.reindexSource(sourceId);
if (result) await loadFilesAndTasks(sourceId);
}
function setError(err: unknown): void {
if (err && typeof err === "object" && "error" in err) {
const result = err as { error?: string | null; apiError?: ApiError | null; diagnostic?: ErrorDiagnostic | null; status?: number };
error.value = result.error || `HTTP ${result.status ?? "unknown"}`;
apiError.value = result.apiError ?? null;
diagnostic.value = result.diagnostic ?? null;
} else {
error.value = err instanceof Error ? err.message : String(err || "MDTODO 加载失败");
apiError.value = null;
diagnostic.value = null;
}
}
</script>
<template>
<section class="route-stack mdtodo-page" data-testid="project-management-mdtodo">
<header class="mdtodo-compact-header">
<div>
<span>Project Management</span>
<h1>MDTODO</h1>
</div>
<p>{{ selectedFileName }}<small v-if="selectedTask"> / {{ selectedTask.taskId }}</small></p>
</header>
<LoadingState v-if="loading && !source.sources.value.length" label="加载 MDTODO source" />
<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" />
</section>
<template v-else>
<MdtodoToolbar
:sources="source.sources.value"
:selected-source-id="selectedSourceId"
:files="taskData.files.value"
:selected-file-ref="selectedFileRef"
:file-loading="taskData.taskLoading.value"
:reindex-loading="source.sourceReindexLoading.value"
:selected-file-name="selectedFileName"
:selected-task-label="selectedTaskLabel"
@update:selected-source-id="selectedSourceId = $event"
@update:selected-file-ref="selectFile($event)"
@open-source-config="openSourceConfig"
@reindex="reindexSource"
@open-info="showInfo = true"
@open-create-task="showTaskCreateDialog = true"
/>
<p v-if="source.sourceMessage.value" class="source-message" data-testid="mdtodo-source-message">{{ source.sourceMessage.value }}</p>
<p v-if="source.sourceError.value" class="source-error" data-testid="mdtodo-source-error">{{ source.sourceError.value }}</p>
<SplitWorkspaceLayout
v-model:left-width="taskPaneWidth"
v-model:right-width="reportPaneWidth"
class="mdtodo-workspace"
:left-collapsed="taskPaneCollapsed"
:right-open="reportPaneOpen"
:right-collapsed="reportPaneCollapsed"
left-resizer-test-id="mdtodo-task-pane-resizer"
right-resizer-test-id="mdtodo-report-pane-resizer"
aria-label="MDTODO 工作区"
>
<template #left>
<MdtodoTaskTree
:tasks="filteredTasks"
:file-scoped-task-count="fileScopedTasks.length"
:task-page="taskData.taskPage.value"
:selected-task-ref="selectedTaskRef"
:collapsed-task-refs="collapsedTaskRefs"
:collapsed="taskPaneCollapsed"
:loading="taskData.taskLoading.value"
:search="taskSearch"
:status-filter="taskStatusFilter"
@update:search="taskSearch = $event"
@update:status-filter="taskStatusFilter = $event"
@update:collapsed="taskPaneCollapsed = $event"
@update:collapsed-task-refs="collapsedTaskRefs = $event"
@select-task="selectTask"
@open-create-task="showTaskCreateDialog = true"
/>
</template>
<MdtodoTaskPanel
:task="selectedTask"
:detail="taskData.taskDetail.value"
:detail-loading="taskData.taskDetailLoading.value"
:detail-error="taskData.taskDetailError.value"
:body-text="selectedTaskBody"
:body-html="selectedTaskBodyHtml"
:links="selectedTaskLinks"
:editing-title="editingTitle"
:editing-body="editingBody"
:edit-title="editTitle"
:edit-status="editStatus"
:edit-body="editBody"
:delete-confirm="deleteConfirm"
:mutation-loading="mutation.taskMutationLoading.value"
:mutation-message="mutation.taskMutationMessage.value"
:mutation-error="mutation.taskMutationError.value"
:launch-loading="launch.launchLoading.value"
:launch-error="launch.launchError.value"
:launch-enabled="workbenchLaunchEnabled"
:navigation="source.navigation.value"
@update:edit-title="editTitle = $event"
@update:edit-status="editStatus = $event"
@update:edit-body="editBody = $event"
@begin-title-edit="editingTitle = true"
@cancel-title-edit="editTitle = selectedTask?.title || ''; editingTitle = false"
@save-task-basics="saveTaskBasics"
@begin-body-edit="editingBody = true"
@cancel-body-edit="editBody = selectedTaskBody; editingBody = false"
@save-task-body="saveTaskBody"
@delete-task="deleteSelectedTask"
@cancel-delete="deleteConfirm = false"
@open-report="openReport"
@launch-workbench="launchWorkbench"
/>
<template #right>
<MdtodoReportPanel
:report="report.reportPreview.value"
:active-link="report.activeReportLink.value"
:report-html="reportPreviewHtml"
:fullscreen-html="reportPreviewHtml"
:loading="report.reportLoading.value"
:error="report.reportError.value"
:collapsed="reportPaneCollapsed"
:show-fullscreen="report.showReportFullscreen.value"
:links="selectedTaskLinks"
@update:collapsed="reportPaneCollapsed = $event"
@open-preview="openReport"
@close="closeReport"
@open-fullscreen="report.openFullscreen()"
@close-fullscreen="report.closeFullscreen()"
/>
</template>
</SplitWorkspaceLayout>
</template>
<MdtodoInfoDialog
:show="showInfo"
:sources="source.sources.value"
:selected-source="selectedSource"
:selected-source-id="selectedSourceId"
:selected-file="selectedFile"
:selected-file-ref="selectedFileRef"
:selected-file-name="selectedFileName"
:selected-task="selectedTask"
:links="taskData.links.value"
:task-page="taskData.taskPage.value"
:file-scoped-task-count="fileScopedTasks.length"
:task-status-counts="taskStatusCounts"
:navigation="source.navigation.value"
@close="showInfo = false"
/>
<MdtodoCreateTaskDialog
:show="showTaskCreateDialog"
:new-title="newTaskTitle"
:new-body="newTaskBody"
:loading="mutation.taskMutationLoading.value"
:error="mutation.taskMutationError.value"
:has-selected-task="Boolean(selectedTask)"
@close="showTaskCreateDialog = false"
@update:new-title="newTaskTitle = $event"
@update:new-body="newTaskBody = $event"
@create="createTask"
/>
<MdtodoSourceDialog
:show="showSourceConfig"
:form="source.sourceForm"
:saving="source.sourceSaving.value"
:probe-loading="source.sourceProbeLoading.value"
:reindex-loading="source.sourceReindexLoading.value"
:message="source.sourceMessage.value"
:error="source.sourceError.value"
@close="showSourceConfig = false"
@save="saveSourceConfig"
@probe="source.probeSource(selectedSourceId)"
@reindex="reindexSource"
/>
</section>
</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: auto auto auto minmax(0, 1fr); gap: 10px; overflow: hidden; }
.mdtodo-compact-header { display: flex; min-width: 0; min-height: 40px; align-items: center; justify-content: space-between; gap: 12px; border-bottom: 1px solid #d8e2df; padding: 0 2px 6px; }
.mdtodo-compact-header span { color: #64706b; font-size: 11px; font-weight: 800; text-transform: uppercase; }
.mdtodo-compact-header h1 { margin: 1px 0 0; color: #14211d; font-size: 18px; line-height: 1.1; }
.mdtodo-compact-header p { min-width: 0; margin: 0; overflow: hidden; color: #52615c; font-size: 12px; font-weight: 750; text-overflow: ellipsis; white-space: nowrap; }
.mdtodo-compact-header small { color: #0f766e; font: inherit; }
.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-error { border: 1px solid #fecaca; background: #fef2f2; color: #991b1b; }
.mdtodo-workspace { min-height: 0; height: 100%; }
.mdtodo-error { display: grid; min-width: 0; min-height: 0; height: 100%; align-content: start; gap: 12px; padding: 12px; overflow: auto; }
@media (max-width: 720px) {
.mdtodo-page { height: calc(100dvh - 68px); max-height: calc(100dvh - 68px); overflow: hidden; }
}
</style>
@@ -0,0 +1,76 @@
<!-- SPEC: PJ2026-01040409 MDTODO前端重写; draft-2026-06-26-p0-mdtodo-web-rewrite. -->
<!-- Responsibility: right-pane report preview open/close/fold/fullscreen, light code blocks, internal scroll. -->
<script setup lang="ts">
import type { MdtodoReportPreviewRecord, MdtodoTaskLinkRecord } from "@/api";
defineProps<{
report: MdtodoReportPreviewRecord | null;
activeLink: MdtodoTaskLinkRecord | null;
reportHtml: string;
fullscreenHtml: string;
loading: boolean;
error: string | null;
collapsed: boolean;
showFullscreen: boolean;
links: MdtodoTaskLinkRecord[];
}>();
const emit = defineEmits<{
"update:collapsed": [value: boolean];
openPreview: [link: MdtodoTaskLinkRecord];
close: [];
openFullscreen: [];
closeFullscreen: [];
}>();
</script>
<template>
<aside class="data-panel report-sidebar" data-testid="mdtodo-report-sidebar" :data-collapsed="collapsed">
<header class="report-sidebar-header">
<button class="icon-button" type="button" data-testid="mdtodo-report-sidebar-toggle" :aria-label="collapsed ? '展开报告侧栏' : '收起报告侧栏'" @click="emit('update:collapsed', !collapsed)">{{ collapsed ? '' : '' }}</button>
<strong v-if="!collapsed" class="report-title">{{ activeLink?.label || report?.relativePath || 'Report' }}</strong>
<div v-if="!collapsed" class="report-sidebar-actions">
<button class="btn btn-secondary" type="button" data-testid="mdtodo-report-fullscreen" :disabled="!report" @click="emit('openFullscreen')">全屏</button>
<button class="icon-button" type="button" aria-label="关闭报告预览" data-testid="mdtodo-report-close" @click="emit('close')">×</button>
</div>
</header>
<article v-if="!collapsed" class="report-preview" data-testid="mdtodo-report-preview">
<div v-if="loading" class="report-loading">加载报告中</div>
<div v-else class="markdown-body" v-html="reportHtml" />
<p v-if="error" class="source-error" data-testid="mdtodo-report-error">{{ error }}</p>
</article>
</aside>
<div v-if="showFullscreen && report" class="modal-backdrop" data-testid="mdtodo-report-fullscreen-dialog" role="dialog" aria-modal="true" aria-label="报告预览" @click.self="emit('closeFullscreen')">
<section class="mdtodo-dialog report-fullscreen-dialog">
<header><h2>{{ activeLink?.label || report.relativePath || 'Report' }}</h2><button class="icon-button" type="button" aria-label="关闭报告" @click="emit('closeFullscreen')">×</button></header>
<article class="markdown-body report-fullscreen-body" v-html="fullscreenHtml" />
</section>
</div>
</template>
<style scoped>
.data-panel { display: grid; min-width: 0; min-height: 0; height: 100%; grid-template-rows: auto minmax(0, 1fr); border: 1px solid #d8e2df; border-radius: 8px; background: #fff; padding: 10px; overflow: hidden; }
.report-sidebar[data-collapsed="true"] { justify-items: center; padding: 8px 4px; grid-template-rows: auto; }
.report-sidebar-header { display: flex; min-width: 0; align-items: center; justify-content: space-between; gap: 8px; color: #14211d; font-size: 13px; }
.report-title { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.report-sidebar-actions { display: inline-flex; flex: 0 0 auto; align-items: center; gap: 6px; }
.report-preview { display: grid; min-height: 0; gap: 8px; border-top: 1px solid #e2ece8; padding-top: 8px; overflow: hidden; }
.report-preview > .markdown-body { min-height: 0; overflow: auto; }
.report-loading { color: #64706b; font-size: 13px; padding: 12px; }
.markdown-body { min-width: 0; color: #14211d; font-size: 13px; line-height: 1.55; overflow-wrap: anywhere; }
.markdown-body :deep(p), .markdown-body :deep(ul), .markdown-body :deep(ol), .markdown-body :deep(pre), .markdown-body :deep(blockquote) { margin: 0 0 8px; }
.markdown-body :deep(pre) { max-width: 100%; overflow: auto; border: 1px solid #d8e2df; border-radius: 6px; background: #f8faf9; color: #14211d; padding: 10px; white-space: pre; }
.markdown-body :deep(code) { border-radius: 4px; background: #eef6f2; padding: 1px 4px; color: #0f766e; }
.markdown-body :deep(pre code) { background: transparent; color: inherit; padding: 0; white-space: inherit; }
.markdown-body :deep(a) { color: #0f766e; font-weight: 750; }
.source-error { margin: 0; border: 1px solid #fecaca; background: #fef2f2; color: #991b1b; border-radius: 6px; padding: 8px 10px; font-size: 13px; }
.icon-button { display: inline-grid; width: 36px; height: 36px; place-items: center; border: 1px solid #cbd8d2; border-radius: 6px; background: #fff; color: #14211d; font-weight: 850; }
.modal-backdrop { position: fixed; z-index: 60; inset: 0; display: grid; place-items: start center; overflow: auto; background: rgba(15, 23, 42, 0.38); padding: 8dvh 16px 32px; }
.mdtodo-dialog { display: grid; width: min(1180px, calc(100vw - 48px)); height: min(820px, calc(100dvh - 64px)); max-height: calc(100dvh - 64px); grid-template-rows: auto minmax(0, 1fr); align-content: stretch; gap: 14px; border: 1px solid #cbd8d2; border-radius: 8px; background: #fff; color: #14211d; padding: 16px; box-shadow: 0 24px 60px rgba(15, 23, 42, 0.22); overflow: hidden; }
.mdtodo-dialog header { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.mdtodo-dialog h2 { margin: 0; font-size: 18px; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.report-fullscreen-body { min-height: 0; height: 100%; overflow: auto; border: 1px solid #e2e8f0; border-radius: 6px; padding: 14px; }
</style>
@@ -0,0 +1,64 @@
<!-- SPEC: PJ2026-01040409 MDTODO前端重写; draft-2026-06-26-p0-mdtodo-web-rewrite. -->
<!-- Responsibility: HWPOD-bound MDTODO source configuration dialog (create/update/probe/reindex). -->
<script setup lang="ts">
import type { ProjectSourceInput } from "@/api";
defineProps<{
show: boolean;
form: ProjectSourceInput;
saving: boolean;
probeLoading: boolean;
reindexLoading: boolean;
message: string | null;
error: string | null;
}>();
const emit = defineEmits<{
close: [];
save: [];
probe: [];
reindex: [];
}>();
</script>
<template>
<div v-if="show" class="modal-backdrop" data-testid="mdtodo-source-config-dialog" role="dialog" aria-modal="true" aria-label="配置 MDTODO Source" @click.self="emit('close')">
<section class="mdtodo-dialog source-config-dialog">
<header><h2>Source 配置</h2><button class="icon-button" type="button" data-testid="mdtodo-source-config-close" aria-label="关闭配置" @click="emit('close')">×</button></header>
<form class="source-form" @submit.prevent="emit('save')">
<label><span>Source ID</span><input v-model="form.sourceId" data-testid="mdtodo-source-form-id" required /></label>
<label><span>Display Name</span><input v-model="form.displayName" data-testid="mdtodo-source-form-name" required /></label>
<label><span>HWPOD ID</span><input v-model="form.hwpodId" data-testid="mdtodo-source-form-hwpod" required /></label>
<label><span>Node ID</span><input v-model="form.nodeId" data-testid="mdtodo-source-form-node" required /></label>
<label><span>Workspace Root</span><input v-model="form.workspaceRootRef" data-testid="mdtodo-source-form-workspace" /></label>
<label><span>MDTODO Root</span><input v-model="form.mdtodoRootRef" data-testid="mdtodo-source-form-root" required /></label>
<label><span>Max Files</span><input v-model.number="form.maxFiles" data-testid="mdtodo-source-form-max-files" type="number" min="1" max="500" /></label>
<footer>
<button class="btn btn-primary" data-testid="mdtodo-source-save" type="submit" :disabled="saving">{{ saving ? '保存中' : '保存' }}</button>
<button class="btn btn-secondary" data-testid="mdtodo-source-probe" type="button" :disabled="probeLoading" @click="emit('probe')">{{ probeLoading ? 'Probe...' : 'Probe' }}</button>
<button class="btn btn-secondary" data-testid="mdtodo-source-reindex-dialog" type="button" :disabled="reindexLoading" @click="emit('reindex')">{{ reindexLoading ? 'Reindex...' : 'Reindex' }}</button>
</footer>
<p v-if="message" class="source-message">{{ message }}</p>
<p v-if="error" class="source-error">{{ error }}</p>
</form>
</section>
</div>
</template>
<style scoped>
.modal-backdrop { position: fixed; z-index: 60; inset: 0; display: grid; place-items: start center; overflow: auto; background: rgba(15, 23, 42, 0.38); padding: 8dvh 16px 32px; }
.mdtodo-dialog { display: grid; width: min(720px, calc(100vw - 32px)); gap: 14px; border: 1px solid #cbd8d2; border-radius: 8px; background: #fff; color: #14211d; padding: 16px; box-shadow: 0 24px 60px rgba(15, 23, 42, 0.22); }
.mdtodo-dialog header { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.mdtodo-dialog h2 { margin: 0; font-size: 18px; }
.icon-button { display: inline-grid; width: 36px; height: 36px; place-items: center; border: 1px solid #cbd8d2; border-radius: 6px; background: #fff; color: #14211d; font-weight: 850; }
.source-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
.source-form label { display: grid; min-width: 0; gap: 5px; color: #52615c; font-size: 12px; font-weight: 800; }
.source-form input { width: 100%; min-width: 0; height: 36px; border: 1px solid #cbd8d2; border-radius: 6px; background: #fff; color: #14211d; padding: 0 10px; font: inherit; }
.source-form footer { display: flex; grid-column: 1 / -1; flex-wrap: wrap; gap: 8px; }
.source-form .source-message, .source-form .source-error { grid-column: 1 / -1; margin: 0; border-radius: 6px; padding: 8px 10px; font-size: 13px; }
.source-message { border: 1px solid #bbf7d0; background: #f0fdf4; color: #166534; }
.source-error { border: 1px solid #fecaca; background: #fef2f2; color: #991b1b; }
@media (max-width: 640px) { .source-form { grid-template-columns: 1fr; } }
</style>
@@ -0,0 +1,167 @@
<!-- SPEC: PJ2026-01040409 MDTODO前端重写; draft-2026-06-26-p0-mdtodo-web-rewrite. -->
<!-- Responsibility: main workspace task title, status, body (the primary content), inline edit, report links, Workbench launch. -->
<script setup lang="ts">
import type { MdtodoTaskDetailRecord, MdtodoTaskLinkRecord, MdtodoTaskRecord, ProjectNavigationResponse } from "@/api";
import LoadingState from "@/components/common/LoadingState.vue";
import EmptyState from "@/components/common/EmptyState.vue";
defineProps<{
task: MdtodoTaskRecord | null;
detail: MdtodoTaskDetailRecord | null;
detailLoading: boolean;
detailError: string | null;
bodyText: string;
bodyHtml: string;
links: MdtodoTaskLinkRecord[];
editingTitle: boolean;
editingBody: boolean;
editTitle: string;
editStatus: string;
editBody: string;
deleteConfirm: boolean;
mutationLoading: boolean;
mutationMessage: string | null;
mutationError: string | null;
launchLoading: boolean;
launchError: string | null;
launchEnabled: boolean;
navigation: ProjectNavigationResponse | null;
}>();
const emit = defineEmits<{
"update:editTitle": [value: string];
"update:editStatus": [value: string];
"update:editBody": [value: string];
beginTitleEdit: [];
cancelTitleEdit: [];
saveTaskBasics: [];
beginBodyEdit: [];
cancelBodyEdit: [];
saveTaskBody: [];
deleteTask: [];
cancelDelete: [];
openReport: [link: MdtodoTaskLinkRecord];
launchWorkbench: [];
}>();
</script>
<template>
<section class="data-panel mdtodo-detail-panel" data-testid="mdtodo-task-detail">
<header class="mdtodo-detail-header">
<div class="task-title-block">
<span class="task-id-large">{{ task?.taskId || 'R?' }}</span>
<div v-if="task && editingTitle" class="inline-editor">
<input :value="editTitle" data-testid="mdtodo-edit-title" :disabled="mutationLoading" @input="emit('update:editTitle', ($event.target as HTMLInputElement).value)" @keyup.enter="emit('saveTaskBasics')" @keyup.esc="emit('cancelTitleEdit')" />
<button class="btn btn-secondary" type="button" data-testid="mdtodo-edit-save" :disabled="mutationLoading" @click="emit('saveTaskBasics')">保存</button>
<button class="btn btn-secondary" type="button" :disabled="mutationLoading" @click="emit('cancelTitleEdit')">取消</button>
</div>
<button v-else-if="task" class="task-title-read" type="button" data-testid="mdtodo-title-read" @dblclick="emit('beginTitleEdit')">{{ task.title || task.taskId }}</button>
<strong v-else>未选择任务</strong>
</div>
<div v-if="task" class="detail-toolbar">
<select :value="editStatus" data-testid="mdtodo-edit-status" :disabled="mutationLoading" aria-label="任务状态" @change="emit('update:editStatus', ($event.target as HTMLSelectElement).value)">
<option value="open">待办</option>
<option value="in_progress">进行中</option>
<option value="done">完成</option>
<option value="blocked">阻塞</option>
</select>
<button class="btn btn-secondary" type="button" data-testid="mdtodo-status-save" :disabled="mutationLoading" @click="emit('saveTaskBasics')">保存状态</button>
<button class="btn btn-primary" type="button" data-testid="mdtodo-workbench-launch" :disabled="!task || !launchEnabled || launchLoading" :aria-disabled="!task || !launchEnabled || launchLoading" @click="emit('launchWorkbench')">{{ launchLoading ? '启动中' : ' Workbench 执行' }}</button>
</div>
</header>
<EmptyState v-if="!task" title="未选择任务" description="从左侧任务树选择一个任务,正文将在此处显示。" />
<template v-else>
<section class="task-detail-content" data-testid="mdtodo-task-editor">
<LoadingState v-if="detailLoading" compact label="同步任务详情" />
<p v-if="detailError" class="source-error" data-testid="mdtodo-task-detail-error">{{ detailError }}</p>
<section class="task-body-section" data-testid="mdtodo-task-body">
<header><strong>正文</strong><small>双击编辑</small></header>
<div v-if="editingBody" class="inline-body-editor">
<textarea :value="editBody" data-testid="mdtodo-edit-body" :disabled="mutationLoading" rows="14" @input="emit('update:editBody', ($event.target as HTMLTextAreaElement).value)" />
<div class="editor-actions">
<button class="btn btn-secondary" type="button" data-testid="mdtodo-edit-body-save" :disabled="mutationLoading" @click="emit('saveTaskBody')">保存正文</button>
<button class="btn btn-secondary" type="button" :disabled="mutationLoading" @click="emit('cancelBodyEdit')">取消</button>
</div>
</div>
<article v-else class="markdown-body task-body-rendered" data-testid="mdtodo-body-rendered" @dblclick="emit('beginBodyEdit')">
<div v-if="bodyHtml" v-html="bodyHtml" />
<p v-else class="empty-inline">暂无正文</p>
</article>
</section>
<section class="report-link-section" data-testid="mdtodo-report-section">
<header><strong>报告</strong><span v-if="links.length">{{ links.length }}</span></header>
<div v-if="links.length" class="report-link-list">
<button v-for="link in links" :key="link.linkId" class="report-link-button" type="button" data-testid="mdtodo-report-link" :disabled="link.kind !== 'markdown-report'" @click="emit('openReport', link)">
<span>{{ link.label || link.href }}</span>
<small>{{ link.relativePath || link.kind }}</small>
</button>
</div>
<p v-else class="empty-inline">暂无报告链接</p>
</section>
<div class="editor-actions danger-actions">
<button class="btn btn-secondary" type="button" data-testid="mdtodo-delete-task" :disabled="mutationLoading" @click="emit('deleteTask')">{{ deleteConfirm ? '确认删除' : '删除任务' }}</button>
<button v-if="deleteConfirm" class="btn btn-secondary" type="button" data-testid="mdtodo-delete-cancel" :disabled="mutationLoading" @click="emit('cancelDelete')">取消</button>
</div>
<p v-if="mutationMessage" class="source-message" data-testid="mdtodo-task-mutation-message">{{ mutationMessage }}</p>
<p v-if="mutationError" class="source-error" data-testid="mdtodo-task-mutation-error">{{ mutationError }}</p>
<p class="launch-blocker" data-testid="mdtodo-workbench-launch-blocker">{{ launchEnabled ? 'Workbench Launch API ready' : 'Workbench Launch capability unavailable' }}</p>
<p v-if="launchError" class="launch-blocker" data-testid="mdtodo-workbench-launch-error">{{ launchError }}</p>
</section>
</template>
</section>
</template>
<style scoped>
.data-panel { display: grid; min-width: 0; min-height: 0; height: 100%; align-content: stretch; grid-template-rows: auto minmax(0, 1fr); gap: 12px; padding: 12px; overflow: hidden; }
.mdtodo-detail-header { display: flex; min-width: 0; align-items: center; justify-content: space-between; gap: 12px; border-bottom: 1px solid #e2ece8; padding-bottom: 8px; }
.task-title-block { display: grid; min-width: 0; grid-template-columns: auto minmax(0, 1fr); gap: 8px; align-items: center; }
.task-id-large { display: inline-flex; min-width: 46px; justify-content: center; border: 1px solid #99f6e4; border-radius: 6px; background: #ecfdf5; color: #0f766e; padding: 5px 8px; font-size: 12px; font-weight: 850; }
.task-title-read { min-width: 0; overflow: hidden; border: 0; border-radius: 6px; background: transparent; color: #14211d; padding: 5px 6px; font: inherit; font-size: 17px; font-weight: 850; text-align: left; text-overflow: ellipsis; white-space: nowrap; }
.task-title-read:hover { background: #f4fbf8; }
.inline-editor { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; gap: 8px; align-items: center; }
.inline-editor input { width: 100%; min-width: 0; border: 1px solid #cbd8d4; border-radius: 6px; color: #14211d; font: inherit; padding: 8px 9px; }
.detail-toolbar { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
.detail-toolbar select { min-width: 112px; height: 34px; border: 1px solid #cbd8d2; border-radius: 6px; background: #fff; color: #14211d; padding: 0 8px; font: inherit; }
.task-detail-content { display: grid; min-width: 0; min-height: 0; align-content: start; grid-template-rows: auto auto minmax(0, 1fr) auto auto auto auto auto; gap: 10px; overflow: auto; }
.task-body-section { display: grid; min-width: 0; min-height: 0; gap: 8px; flex: 1 1 auto; }
.task-body-section header { display: flex; min-width: 0; align-items: center; justify-content: space-between; gap: 10px; color: #14211d; font-size: 13px; }
.task-body-section header small { color: #64706b; font-size: 12px; }
.task-body-rendered { min-height: 120px; overflow: auto; border: 1px solid #d8e2df; border-radius: 6px; background: #fff; padding: 14px; flex: 1 1 auto; }
.markdown-body { min-width: 0; color: #14211d; font-size: 13px; line-height: 1.6; overflow-wrap: anywhere; }
.markdown-body :deep(p), .markdown-body :deep(ul), .markdown-body :deep(ol), .markdown-body :deep(pre), .markdown-body :deep(blockquote) { margin: 0 0 10px; }
.markdown-body :deep(pre) { max-width: 100%; overflow: auto; border: 1px solid #d8e2df; border-radius: 6px; background: #f8faf9; color: #14211d; padding: 10px; white-space: pre; }
.markdown-body :deep(code) { border-radius: 4px; background: #eef6f2; padding: 1px 4px; color: #0f766e; }
.markdown-body :deep(pre code) { background: transparent; color: inherit; padding: 0; white-space: inherit; }
.markdown-body :deep(a) { color: #0f766e; font-weight: 750; }
.empty-inline { margin: 0; color: #64706b; }
.inline-body-editor { display: grid; gap: 8px; }
.inline-body-editor textarea { width: 100%; min-width: 0; min-height: 240px; border: 1px solid #cbd8d4; border-radius: 6px; background: #fff; color: #14211d; font: inherit; line-height: 1.5; padding: 10px; resize: vertical; }
.report-link-section { display: grid; min-height: 0; gap: 8px; border: 1px solid #d8e2df; border-radius: 8px; background: #fff; padding: 10px; }
.report-link-section header { display: flex; min-width: 0; align-items: center; justify-content: space-between; gap: 10px; color: #14211d; font-size: 13px; }
.report-link-section header span { color: #64706b; font-size: 12px; }
.report-link-list { display: grid; gap: 6px; }
.report-link-button { display: grid; width: 100%; min-width: 0; gap: 3px; border: 1px solid #d8e2df; border-radius: 6px; background: #fff; color: #14211d; padding: 8px 10px; text-align: left; }
.report-link-button:disabled { opacity: 0.55; }
.report-link-button span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 800; }
.report-link-button small { color: #64706b; overflow-wrap: anywhere; }
.editor-actions { display: flex; flex-wrap: wrap; gap: 8px; }
.danger-actions { border-top: 1px solid #e2ece8; padding-top: 10px; }
.danger-actions .btn { border-color: #fecaca; color: #991b1b; }
.source-message { margin: 0; border: 1px solid #bbf7d0; background: #f0fdf4; color: #166534; border-radius: 6px; padding: 8px 10px; font-size: 13px; }
.source-error { margin: 0; border: 1px solid #fecaca; background: #fef2f2; color: #991b1b; border-radius: 6px; padding: 8px 10px; font-size: 13px; }
.launch-blocker { margin: 0; color: #64706b; font-size: 12px; }
@media (max-width: 640px) {
.mdtodo-detail-header { flex-direction: column; align-items: stretch; }
.detail-toolbar { justify-content: flex-start; }
.task-title-block { grid-template-columns: 1fr; }
.inline-editor { grid-template-columns: 1fr; }
}
</style>
@@ -0,0 +1,104 @@
<!-- SPEC: PJ2026-01040409 MDTODO前端重写; draft-2026-06-26-p0-mdtodo-web-rewrite. -->
<!-- Responsibility: Rxx outline tree fold, search, status filter, current highlight, deep-link auto-expand. -->
<script setup lang="ts">
import type { MdtodoTaskPage, MdtodoTaskRecord } from "@/api";
import LoadingState from "@/components/common/LoadingState.vue";
import EmptyState from "@/components/common/EmptyState.vue";
import { statusLabel, taskIndent as indentStyle, useRxxTaskTree } from "@/composables/mdtodo/useMdtodoDisplay";
const props = defineProps<{
tasks: MdtodoTaskRecord[];
fileScopedTaskCount: number;
taskPage: MdtodoTaskPage | null;
selectedTaskRef: string | null;
collapsedTaskRefs: Set<string>;
collapsed: boolean;
loading: boolean;
search: string;
statusFilter: string;
}>();
const emit = defineEmits<{
"update:search": [value: string];
"update:statusFilter": [value: string];
"update:collapsed": [value: boolean];
"update:collapsedTaskRefs": [value: Set<string>];
selectTask: [taskRef: string];
openCreateTask: [];
}>();
const tree = useRxxTaskTree(
() => props.tasks,
() => props.collapsedTaskRefs
);
function toggleTask(task: MdtodoTaskRecord): void {
const next = new Set(props.collapsedTaskRefs);
if (next.has(task.taskRef)) next.delete(task.taskRef);
else next.add(task.taskRef);
emit("update:collapsedTaskRefs", next);
}
</script>
<template>
<section class="data-panel mdtodo-task-panel" data-testid="mdtodo-task-tree" :data-collapsed="collapsed">
<header class="mdtodo-panel-header">
<div><h2>{{ collapsed ? '纲' : '任务树' }}</h2><p v-if="!collapsed">{{ tree.visibleTaskRows.value.length }} / {{ taskPage?.total ?? fileScopedTaskCount }} tasks<span v-if="taskPage?.hasMore"> · window {{ taskPage.limit }}</span></p></div>
<div class="task-pane-actions">
<button class="icon-button" type="button" data-testid="mdtodo-new-task-open" aria-label="新建任务" @click="emit('openCreateTask')">+</button>
<button class="icon-button" type="button" data-testid="mdtodo-task-pane-toggle" :aria-label="collapsed ? '展开任务树' : '收起任务树'" @click="emit('update:collapsed', !collapsed)">{{ collapsed ? '' : '' }}</button>
</div>
<div v-if="!collapsed" class="task-tools">
<input :value="search" data-testid="mdtodo-task-search" type="search" placeholder="R1 / title" aria-label="搜索任务" @input="emit('update:search', ($event.target as HTMLInputElement).value)" />
<select :value="statusFilter" data-testid="mdtodo-task-status-filter" aria-label="筛选状态" @change="emit('update:statusFilter', ($event.target as HTMLSelectElement).value)">
<option value="all">全部</option>
<option value="open">待办</option>
<option value="in_progress">进行中</option>
<option value="done">完成</option>
<option value="blocked">阻塞</option>
</select>
</div>
</header>
<template v-if="!collapsed">
<LoadingState v-if="loading" compact label="同步文件和任务" />
<EmptyState v-else-if="!tree.visibleTaskRows.value.length" title="暂无任务" description="当前文件没有任务投影。" />
<div v-else class="task-tree" role="tree" aria-label="MDTODO task tree">
<div v-for="task in tree.visibleTaskRows.value" :key="task.taskRef" class="task-row-shell" :style="indentStyle(task)">
<button class="task-toggle" type="button" :data-testid="tree.hasChildren(task) ? 'mdtodo-task-toggle' : undefined" :aria-label="props.collapsedTaskRefs.has(task.taskRef) ? '展开任务' : '折叠任务'" :disabled="!tree.hasChildren(task)" @click.stop="toggleTask(task)">{{ tree.hasChildren(task) ? (props.collapsedTaskRefs.has(task.taskRef) ? '+' : '-') : '' }}</button>
<button class="task-row" role="treeitem" type="button" :data-selected="task.taskRef === selectedTaskRef" :data-task-ref="task.taskRef" :data-task-id="task.taskId" :data-task-status="task.status || 'unknown'" @click="emit('selectTask', task.taskRef)">
<span class="task-id">{{ task.taskId || 'R?' }}</span>
<strong>{{ task.title || task.taskId || 'Untitled task' }}</strong>
<span class="task-status" :data-status="task.status || 'unknown'">{{ statusLabel(task.status) }}</span>
</button>
</div>
</div>
</template>
</section>
</template>
<style scoped>
.data-panel { display: grid; min-width: 0; min-height: 0; height: 100%; align-content: start; gap: 12px; padding: 12px; overflow: hidden; grid-template-rows: auto auto minmax(0, 1fr); }
.mdtodo-task-panel[data-collapsed="true"] { justify-items: center; padding: 8px 4px; overflow: hidden; grid-template-rows: auto auto; }
.mdtodo-panel-header { display: flex; min-width: 0; align-items: center; justify-content: space-between; gap: 12px; }
.mdtodo-panel-header h2 { margin: 0; color: #14211d; font-size: 16px; line-height: 1.2; }
.mdtodo-panel-header p { margin: 3px 0 0; color: #64706b; font-size: 12px; }
.task-pane-actions { display: flex; gap: 6px; margin-left: auto; }
.task-tools { display: grid; width: min(360px, 100%); grid-template-columns: minmax(130px, 1fr) minmax(110px, 0.7fr); gap: 8px; }
.task-tools input, .task-tools select { width: 100%; min-width: 0; height: 36px; border: 1px solid #cbd8d2; border-radius: 6px; background: #fff; color: #14211d; padding: 0 10px; font: inherit; }
.task-tree { display: grid; min-height: 0; gap: 5px; overflow: auto; }
.task-row-shell { display: grid; grid-template-columns: 24px minmax(0, 1fr); gap: 4px; align-items: center; }
.task-toggle { width: 24px; height: 24px; border: 1px solid #d8e2df; border-radius: 4px; background: #fff; color: #31423c; font-weight: 850; line-height: 1; }
.task-toggle:disabled { border-color: transparent; background: transparent; }
.task-row { display: grid; width: 100%; min-width: 0; grid-template-columns: minmax(52px, 0.22fr) minmax(0, 1fr) auto; gap: 8px; align-items: center; border: 1px solid #d8e2df; border-radius: 6px; background: #fff; color: #14211d; padding: 8px 10px; text-align: left; }
.task-row[data-selected="true"] { border-color: #0f766e; background: #ecfdf5; box-shadow: inset 3px 0 0 #0f766e; }
.task-id { color: #0f766e; font-size: 12px; font-weight: 850; }
.task-row strong { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.task-status { display: inline-flex; width: fit-content; max-width: 100%; align-items: center; border-radius: 999px; border: 1px solid #bae6fd; background: #f0f9ff; color: #075985; padding: 4px 8px; font-size: 11px; font-weight: 750; line-height: 1; }
.task-status[data-status="done"] { border-color: #86efac; background: #dcfce7; color: #166534; }
.task-status[data-status="in_progress"] { border-color: #fde68a; background: #fffbeb; color: #92400e; }
.task-status[data-status="blocked"] { border-color: #fed7aa; background: #fff7ed; color: #9a3412; }
.icon-button { display: inline-grid; width: 36px; height: 36px; place-items: center; border: 1px solid #cbd8d2; border-radius: 6px; background: #fff; color: #14211d; font-weight: 850; }
.icon-button:disabled { opacity: 0.5; }
</style>
@@ -0,0 +1,82 @@
<!-- SPEC: PJ2026-01040409 MDTODO前端重写; draft-2026-06-26-p0-mdtodo-web-rewrite. -->
<!-- Responsibility: narrow status toolbar Source/File dropdowns, search, status filter, info/settings/create/reindex actions. -->
<script setup lang="ts">
import type { MdtodoFileRecord, ProjectSource } from "@/api";
import { fileDisplayName } from "@/composables/mdtodo/useMdtodoDisplay";
defineProps<{
sources: ProjectSource[];
selectedSourceId: string | null;
files: MdtodoFileRecord[];
selectedFileRef: string | null;
fileLoading: boolean;
reindexLoading: boolean;
selectedFileName: string;
selectedTaskLabel: string;
}>();
const emit = defineEmits<{
"update:selectedSourceId": [value: string | null];
"update:selectedFileRef": [value: string];
openSourceConfig: [];
reindex: [];
openInfo: [];
openCreateTask: [];
}>();
function onSourceSelect(event: Event): void {
emit("update:selectedSourceId", (event.target as HTMLSelectElement).value || null);
}
function onFileSelect(event: Event): void {
emit("update:selectedFileRef", (event.target as HTMLSelectElement).value);
}
</script>
<template>
<section class="mdtodo-toolbar" data-testid="mdtodo-toolbar" aria-label="MDTODO controls">
<label class="toolbar-field" data-testid="mdtodo-source-list">
<span>Source</span>
<select data-testid="mdtodo-source-select" :value="selectedSourceId || ''" @change="onSourceSelect">
<option v-for="source in sources" :key="source.sourceId" :value="source.sourceId">{{ source.displayName || source.sourceId }}</option>
</select>
</label>
<label class="toolbar-field" data-testid="mdtodo-file-control">
<span>File</span>
<select data-testid="mdtodo-file-select" :value="selectedFileRef || ''" :disabled="fileLoading || !files.length" @change="onFileSelect">
<option v-for="file in files" :key="file.fileRef" :value="file.fileRef">{{ fileDisplayName(file) }}</option>
</select>
</label>
<div class="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">
<button class="btn btn-secondary" 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-reindex" aria-label="重建索引" :disabled="!selectedSourceId || reindexLoading" @click="emit('reindex')"></button>
<button class="icon-button" type="button" data-testid="mdtodo-info-open" aria-label="查看摘要" @click="emit('openInfo')"></button>
</div>
</section>
</template>
<style scoped>
.mdtodo-toolbar { display: grid; grid-template-columns: minmax(180px, 0.8fr) minmax(220px, 1fr) minmax(0, 1fr) auto; gap: 10px; align-items: end; border: 1px solid #d8e2df; border-radius: 8px; background: #fbfdfb; padding: 8px; }
.toolbar-field { display: grid; min-width: 0; gap: 5px; }
.toolbar-field span { color: #52615c; font-size: 11px; font-weight: 800; text-transform: uppercase; }
.toolbar-field select { width: 100%; min-width: 0; height: 36px; border: 1px solid #cbd8d2; border-radius: 6px; background: #fff; color: #14211d; padding: 0 10px; font: inherit; }
.toolbar-context { display: grid; min-width: 0; gap: 2px; align-content: center; padding: 0 4px; }
.toolbar-file-name { min-width: 0; overflow: hidden; color: #14211d; font-size: 12px; font-weight: 750; text-overflow: ellipsis; white-space: nowrap; }
.toolbar-task-label { color: #0f766e; font-size: 11px; font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.toolbar-actions { display: flex; gap: 8px; justify-content: flex-end; align-items: center; }
.icon-button { display: inline-grid; width: 36px; height: 36px; place-items: center; border: 1px solid #cbd8d2; border-radius: 6px; background: #fff; color: #14211d; font-weight: 850; }
.icon-button:disabled { opacity: 0.5; }
@media (max-width: 980px) {
.mdtodo-toolbar { grid-template-columns: 1fr 1fr auto; }
.toolbar-context { display: none; }
}
@media (max-width: 640px) {
.mdtodo-toolbar { grid-template-columns: 1fr; }
}
</style>
@@ -0,0 +1,86 @@
// SPEC: PJ2026-01040409 MDTODO前端重写; draft-2026-06-26-p0-mdtodo-web-rewrite.
// Responsibility: shared display helpers and Rxx tree derivation (no business authority).
import { computed } from "vue";
import type { ComputedRef } from "vue";
import type { MdtodoFileRecord, MdtodoTaskRecord } from "@/api";
/** File dropdown shows the direct docs/MDTODO/ file name, not internal titles. */
export function fileDisplayName(file: MdtodoFileRecord): string {
const path = file.relativePath || file.fileRef || "-";
return path.split("/").filter(Boolean).at(-1) || path;
}
export function statusLabel(value?: string | null): string {
if (value === "done") return "完成";
if (value === "blocked") return "阻塞";
if (value === "in_progress") return "进行中";
if (value === "open") return "待办";
return value || "未知";
}
export function sourceStatusTone(value?: string | null): string {
if (value === "active" || value === "ok" || value === "ready") return "ok";
if (value === "blocked" || value === "error" || value === "failed") return "error";
return "pending";
}
export function displayDate(value?: string | null): string {
if (!value) return "-";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" });
}
export function shortRef(value?: string | null): string {
if (!value) return "-";
return value.length > 42 ? `${value.slice(0, 24)}...${value.slice(-10)}` : value;
}
/** Indent style for a task row based on its Rxx depth. */
export function taskIndent(task: MdtodoTaskRecord): Record<string, string> {
return { paddingInlineStart: `${12 + Math.max(0, task.depth ?? 0) * 22}px` };
}
/**
* Rxx task tree: R1 is a root, R1.1 is a child of R1, R1.1.1 is a child of R1.1.
* Hierarchy is derived from taskRef/rxxId prefixes, NOT from Markdown heading
* counts or checkbox indentation. Returns children-by-parent map and visible rows.
*/
export function useRxxTaskTree(
tasks: ComputedRef<MdtodoTaskRecord[]> | (() => MdtodoTaskRecord[]),
collapsedTaskRefs: () => Set<string>
) {
const taskList = computed(() => (typeof tasks === "function" ? (tasks as () => MdtodoTaskRecord[])() : tasks.value));
const childrenByParent = computed(() => {
const byRef = new Map(taskList.value.map((task) => [task.taskRef, task]));
const map = new Map<string, MdtodoTaskRecord[]>();
for (const task of taskList.value) {
const parent = task.parentTaskRef && byRef.has(task.parentTaskRef) ? task.parentTaskRef : "root";
const list = map.get(parent) ?? [];
list.push(task);
map.set(parent, list);
}
for (const list of map.values()) list.sort((a, b) => (a.ordinal ?? 0) - (b.ordinal ?? 0) || String(a.taskId ?? "").localeCompare(String(b.taskId ?? "")));
return map;
});
const visibleTaskRows = computed(() => {
const rows: MdtodoTaskRecord[] = [];
const visit = (task: MdtodoTaskRecord) => {
rows.push(task);
if (collapsedTaskRefs().has(task.taskRef)) return;
for (const child of childrenByParent.value.get(task.taskRef) ?? []) visit(child);
};
for (const task of childrenByParent.value.get("root") ?? []) visit(task);
return rows;
});
function hasChildren(task: MdtodoTaskRecord): boolean {
return (childrenByParent.value.get(task.taskRef) ?? []).length > 0;
}
return { childrenByParent, visibleTaskRows, hasChildren, taskIndent };
}
@@ -0,0 +1,86 @@
// SPEC: PJ2026-01040409 MDTODO前端重写; draft-2026-06-26-p0-mdtodo-web-rewrite.
// Responsibility: report link preview, fullscreen and close/restore via public API.
import { ref } from "vue";
import DOMPurify from "dompurify";
import { marked } from "marked";
import { projectManagementAPI, type MdtodoReportPreviewRecord, type MdtodoTaskLinkRecord } from "@/api";
/**
* Parses task-body Markdown links into report links, opens the right-pane
* preview, supports fullscreen and close-restore. Reports use a mature Markdown
* renderer with sanitization and light Workbench-style code blocks; long
* JSON/RPC scrolls inside the pane. Report files never enter the File dropdown.
*/
export function useMdtodoReportPreview() {
const reportPreview = ref<MdtodoReportPreviewRecord | null>(null);
const activeReportLink = ref<MdtodoTaskLinkRecord | null>(null);
const reportLoading = ref(false);
const reportError = ref<string | null>(null);
const showReportFullscreen = ref(false);
function markdownToHtml(value?: string | null): string {
const text = String(value ?? "").trim();
if (!text) return "";
return DOMPurify.sanitize(marked.parse(text, { async: false }) as string);
}
async function openReportPreview(taskRef: string, link: MdtodoTaskLinkRecord): Promise<MdtodoReportPreviewRecord | null> {
if (!link.linkId || link.kind !== "markdown-report") return null;
reportLoading.value = true;
reportError.value = null;
try {
const response = await projectManagementAPI.reportPreview(taskRef, link.linkId);
if (!response.ok) throw response;
const report = response.data?.report ?? null;
if (!report?.content) throw new Error("报告内容未返回");
reportPreview.value = report;
activeReportLink.value = response.data?.link ?? link;
return report;
} catch (err) {
reportError.value = err && typeof err === "object" && "error" in err ? String((err as { error?: string }).error || "报告加载失败") : "报告加载失败";
return null;
} finally {
reportLoading.value = false;
}
}
async function openReportPreviewById(taskRef: string, links: MdtodoTaskLinkRecord[], linkId: string): Promise<MdtodoReportPreviewRecord | null> {
const target = links.find((link) => link.linkId === linkId || link.label === linkId || link.relativePath?.includes(linkId) || link.href?.includes(linkId));
if (!target) {
reportError.value = `报告链接未找到: ${linkId}`;
return null;
}
return openReportPreview(taskRef, target);
}
function closeReportPreview(): void {
reportPreview.value = null;
activeReportLink.value = null;
showReportFullscreen.value = false;
reportError.value = null;
}
function openFullscreen(): void {
if (reportPreview.value) showReportFullscreen.value = true;
}
function closeFullscreen(): void {
showReportFullscreen.value = false;
}
return {
reportPreview,
activeReportLink,
reportLoading,
reportError,
showReportFullscreen,
markdownToHtml,
openReportPreview,
openReportPreviewById,
closeReportPreview,
openFullscreen,
closeFullscreen
};
}
@@ -0,0 +1,80 @@
// SPEC: PJ2026-01040409 MDTODO前端重写; draft-2026-06-26-p0-mdtodo-web-rewrite.
// Responsibility: URL is the authority for source/file/task/report selection; RESTful deep-link restore.
import { ref } from "vue";
import type { Router, RouteLocationNormalized } from "vue-router";
export type RouteName = "ProjectMdtodo" | "ProjectMdtodoFile" | "ProjectMdtodoTask" | "ProjectMdtodoReport";
export interface MdtodoRouteSelection {
sourceId: string | null;
fileRef: string | null;
taskId: string | null;
linkId: string | null;
}
export interface UseMdtodoRouteSelectionOptions {
router: Router;
route: RouteLocationNormalized;
}
/**
* URL is the authority for selection state. Components may cache fold widths and
* edit drafts locally, but source/file/task/report business selection must be
* restorable from the URL after refresh, back/forward or web-probe deep links.
*/
export function useMdtodoRouteSelection({ router, route }: UseMdtodoRouteSelectionOptions) {
const applyingRouteSelection = ref(false);
function routeParam(key: string): string | null {
const value = (route.params as Record<string, unknown>)[key];
const text = Array.isArray(value) ? value[0] : value;
return typeof text === "string" && text.trim() ? text.trim() : null;
}
function readSelection(): MdtodoRouteSelection {
return {
sourceId: routeParam("sourceId"),
fileRef: routeParam("fileRef"),
taskId: routeParam("taskId"),
linkId: routeParam("linkId")
};
}
function routeSelectionOptions(updateRoute: boolean): { fileRef?: string | null; taskId?: string | null; reportLinkId?: string | null; updateRoute: boolean } {
return {
fileRef: routeParam("fileRef"),
taskId: routeParam("taskId"),
reportLinkId: routeParam("linkId"),
updateRoute
};
}
async function syncSelectionRoute(options: {
sourceId: string | null;
fileRef: string | null;
taskId?: string | null;
reportLinkId?: string | null;
replace?: boolean;
}): Promise<void> {
const { sourceId, fileRef, taskId, reportLinkId, replace } = options;
if (!sourceId || !fileRef) return;
const name: RouteName = reportLinkId && taskId ? "ProjectMdtodoReport" : taskId ? "ProjectMdtodoTask" : "ProjectMdtodoFile";
const params: Record<string, string> = { sourceId, fileRef };
if (taskId) params.taskId = taskId;
if (reportLinkId) params.linkId = reportLinkId;
const navigation = { name, params };
const resolved = router.resolve(navigation);
if (resolved.fullPath === route.fullPath) return;
if (replace) await router.replace(navigation);
else await router.push(navigation);
}
return {
applyingRouteSelection,
readSelection,
routeSelectionOptions,
syncSelectionRoute
};
}
@@ -0,0 +1,157 @@
// SPEC: PJ2026-01040409 MDTODO前端重写; draft-2026-06-26-p0-mdtodo-web-rewrite.
// Responsibility: MDTODO source list, HWPOD-bound source config, probe and reindex via public API.
import { reactive, ref } from "vue";
import { projectManagementAPI, type ProjectSource, type ProjectSourceInput, type ProjectNavigationResponse } from "@/api";
import type { ApiError, ErrorDiagnostic } from "@/types";
export interface UseMdtodoSourceReturn {
sources: ReturnType<typeof ref<ProjectSource[]>>;
navigation: ReturnType<typeof ref<ProjectNavigationResponse | null>>;
sourceForm: ReturnType<typeof reactive<ProjectSourceInput>>;
sourceMessage: ReturnType<typeof ref<string | null>>;
sourceError: ReturnType<typeof ref<string | null>>;
sourceSaving: ReturnType<typeof ref<boolean>>;
sourceProbeLoading: ReturnType<typeof ref<boolean>>;
sourceReindexLoading: ReturnType<typeof ref<boolean>>;
loadNavigationAndSources: () => Promise<{ navigation: ProjectNavigationResponse | null; sources: ProjectSource[] }>;
openSourceForm: (source: ProjectSource | null) => void;
saveSourceConfig: () => Promise<string | null>;
probeSource: (sourceId?: string | null) => Promise<void>;
reindexSource: (sourceId: string) => Promise<{ documentCount: number; taskCount: number } | null>;
clearSourceMessages: () => void;
}
function apiErrorOf(err: unknown): { error: string; apiError: ApiError | null; diagnostic: ErrorDiagnostic | null } {
if (err && typeof err === "object" && "error" in err) {
const result = err as { error?: string | null; apiError?: ApiError | null; diagnostic?: ErrorDiagnostic | null; status?: number };
return { error: result.error || `HTTP ${result.status ?? "unknown"}`, apiError: result.apiError ?? null, diagnostic: result.diagnostic ?? null };
}
return { error: err instanceof Error ? err.message : String(err || "MDTODO source 操作失败"), apiError: null, diagnostic: null };
}
export function useMdtodoSource() {
const sources = ref<ProjectSource[]>([]);
const navigation = ref<ProjectNavigationResponse | null>(null);
const sourceForm = reactive<ProjectSourceInput>({
sourceId: "d601-f103-v2-mdtodo",
sourceKind: "hwpod-workspace",
displayName: "D601 F103 MDTODO",
projectId: "project_hwlab_v03",
hwpodId: "d601-f103-v2",
nodeId: "node-d601-f103-v2",
workspaceRootRef: "",
mdtodoRootRef: "docs/MDTODO/",
maxFiles: 120
});
const sourceMessage = ref<string | null>(null);
const sourceError = ref<string | null>(null);
const sourceSaving = ref(false);
const sourceProbeLoading = ref(false);
const sourceReindexLoading = ref(false);
async function loadNavigationAndSources(): Promise<{ navigation: ProjectNavigationResponse | null; sources: ProjectSource[] }> {
const [navigationResponse, sourceResponse] = await Promise.all([projectManagementAPI.navigation(), projectManagementAPI.sources()]);
if (!navigationResponse.ok) throw navigationResponse;
if (!sourceResponse.ok) throw sourceResponse;
navigation.value = navigationResponse.data ?? null;
sources.value = sourceResponse.data?.sources ?? [];
return { navigation: navigation.value, sources: sources.value };
}
function openSourceForm(source: ProjectSource | null): void {
sourceMessage.value = null;
sourceError.value = null;
Object.assign(sourceForm, {
sourceId: source?.sourceKind === "hwpod-workspace" ? source.sourceId : "d601-f103-v2-mdtodo",
sourceKind: "hwpod-workspace",
displayName: source?.sourceKind === "hwpod-workspace" ? source.displayName : "D601 F103 MDTODO",
projectId: source?.projectId || "project_hwlab_v03",
hwpodId: source?.hwpodId || "d601-f103-v2",
nodeId: source?.nodeId || "node-d601-f103-v2",
workspaceRootRef: source?.workspaceRootRef || "",
mdtodoRootRef: source?.mdtodoRootRef || "docs/MDTODO/",
maxFiles: source?.maxFiles || 120
});
}
async function saveSourceConfig(): Promise<string | null> {
sourceSaving.value = true;
sourceMessage.value = null;
sourceError.value = null;
try {
const sourceId = String(sourceForm.sourceId || "").trim();
const exists = Boolean(sourceId && sources.value.some((source) => source.sourceId === sourceId));
const response = exists ? await projectManagementAPI.updateSource(sourceId, sourceForm) : await projectManagementAPI.createSource(sourceForm);
if (!response.ok) throw response;
const savedId = response.data?.source?.sourceId ?? sourceId;
sourceMessage.value = "Source 已保存";
await loadNavigationAndSources();
return savedId;
} catch (err) {
sourceError.value = apiErrorOf(err).error;
return null;
} finally {
sourceSaving.value = false;
}
}
async function probeSource(sourceId?: string | null): Promise<void> {
const id = sourceId || sourceForm.sourceId;
if (!id) return;
sourceProbeLoading.value = true;
sourceMessage.value = null;
sourceError.value = null;
try {
const response = await projectManagementAPI.probeSource(id);
if (!response.ok) throw response;
sourceMessage.value = `Probe ${response.data?.probe?.status || "ok"}`;
} catch (err) {
sourceError.value = apiErrorOf(err).error;
} finally {
sourceProbeLoading.value = false;
}
}
async function reindexSource(sourceId: string): Promise<{ documentCount: number; taskCount: number } | null> {
sourceReindexLoading.value = true;
sourceMessage.value = null;
sourceError.value = null;
try {
const response = await projectManagementAPI.reindexSource(sourceId);
if (!response.ok) throw response;
const documentCount = response.data?.projection?.documentCount ?? 0;
const taskCount = response.data?.projection?.taskCount ?? 0;
sourceMessage.value = `Reindex ${documentCount} files / ${taskCount} tasks`;
return { documentCount, taskCount };
} catch (err) {
sourceError.value = apiErrorOf(err).error;
return null;
} finally {
sourceReindexLoading.value = false;
}
}
function clearSourceMessages(): void {
sourceMessage.value = null;
sourceError.value = null;
}
return {
sources,
navigation,
sourceForm,
sourceMessage,
sourceError,
sourceSaving,
sourceProbeLoading,
sourceReindexLoading,
loadNavigationAndSources,
openSourceForm,
saveSourceConfig,
probeSource,
reindexSource,
clearSourceMessages
};
}
@@ -0,0 +1,104 @@
// SPEC: PJ2026-01040409 MDTODO前端重写; draft-2026-06-26-p0-mdtodo-web-rewrite.
// Responsibility: file list, Rxx task window, task detail and workbench links loading via public API.
import { computed, ref } from "vue";
import { projectManagementAPI, type MdtodoFileRecord, type MdtodoTaskRecord, type MdtodoTaskDetailRecord, type MdtodoTaskPage, type ProjectWorkbenchLinkRecord } from "@/api";
const TASK_WINDOW_LIMIT = 200;
/**
* Loads MDTODO files, the Rxx task window for a selected file, task detail body
* and workbench links through the public Project Management API. The Rxx tree
* hierarchy (R1/R1.1/R1.1.1) is derived from taskRef prefixes, not from Markdown
* heading counts or checkbox indentation.
*/
export function useMdtodoTaskData() {
const files = ref<MdtodoFileRecord[]>([]);
const tasks = ref<MdtodoTaskRecord[]>([]);
const taskPage = ref<MdtodoTaskPage | null>(null);
const taskDetail = ref<MdtodoTaskDetailRecord | null>(null);
const links = ref<ProjectWorkbenchLinkRecord[]>([]);
const taskLoading = ref(false);
const taskDetailLoading = ref(false);
const linkLoading = ref(false);
const taskDetailError = ref<string | null>(null);
const taskByRef = computed(() => new Map(tasks.value.map((task) => [task.taskRef, task])));
async function loadFiles(sourceId: string): Promise<MdtodoFileRecord[]> {
const fileResponse = await projectManagementAPI.files(sourceId);
if (!fileResponse.ok) throw fileResponse;
files.value = fileResponse.data?.files ?? [];
return files.value;
}
async function loadTaskWindow(sourceId: string, fileRef: string | null, preferredTaskRef?: string | null): Promise<MdtodoTaskRecord[]> {
const taskResponse = await projectManagementAPI.tasks({ sourceId, fileRef, limit: TASK_WINDOW_LIMIT });
if (!taskResponse.ok) throw taskResponse;
tasks.value = taskResponse.data?.tasks ?? [];
taskPage.value = taskResponse.data?.page ?? null;
return tasks.value;
}
function resolvePreferredTask(preferredTaskRef?: string | null): MdtodoTaskRecord | null {
if (!preferredTaskRef) return null;
return tasks.value.find((task) => task.taskRef === preferredTaskRef || task.taskId === preferredTaskRef || task.rxxId === preferredTaskRef) ?? null;
}
async function loadTaskDetail(taskRef: string | null): Promise<void> {
taskDetailError.value = null;
if (!taskRef) {
taskDetail.value = null;
return;
}
taskDetailLoading.value = true;
try {
const response = await projectManagementAPI.taskDetail(taskRef);
if (!response.ok) throw response;
taskDetail.value = response.data?.detail ?? null;
} catch (err) {
taskDetail.value = null;
taskDetailError.value = err && typeof err === "object" && "error" in err ? String((err as { error?: string }).error || "任务详情加载失败") : "任务详情加载失败";
} finally {
taskDetailLoading.value = false;
}
}
async function loadLinks(taskRef: string | null): Promise<void> {
linkLoading.value = true;
try {
const response = await projectManagementAPI.workbenchLinks(taskRef ? { taskRef } : {});
if (!response.ok) throw response;
links.value = response.data?.links ?? [];
} catch {
links.value = [];
} finally {
linkLoading.value = false;
}
}
function clearTaskDetailAndReport(): void {
taskDetail.value = null;
taskDetailError.value = null;
}
return {
files,
tasks,
taskPage,
taskDetail,
links,
taskLoading,
taskDetailLoading,
linkLoading,
taskDetailError,
taskByRef,
loadFiles,
loadTaskWindow,
resolvePreferredTask,
loadTaskDetail,
loadLinks,
clearTaskDetailAndReport
};
}
@@ -0,0 +1,99 @@
// SPEC: PJ2026-01040409 MDTODO前端重写; draft-2026-06-26-p0-mdtodo-web-rewrite.
// Responsibility: task create/update/delete via public API with revision/fingerprint concurrency protection.
import { ref } from "vue";
import { projectManagementAPI, type MdtodoTaskMutationInput, type MdtodoTaskRecord } from "@/api";
import type { ApiError, ErrorDiagnostic } from "@/types";
function mutationErrorOf(err: unknown): { error: string; apiError: ApiError | null; diagnostic: ErrorDiagnostic | null } {
if (err && typeof err === "object" && "error" in err) {
const result = err as { error?: string | null; apiError?: ApiError | null; diagnostic?: ErrorDiagnostic | null; status?: number };
return { error: result.error || `HTTP ${result.status ?? "unknown"}`, apiError: result.apiError ?? null, diagnostic: result.diagnostic ?? null };
}
return { error: err instanceof Error ? err.message : String(err || "任务操作失败"), apiError: null, diagnostic: null };
}
/**
* All task mutations go through the public Project Management API and carry an
* expectedFingerprint/revision for optimistic-concurrency protection. The frontend
* may optimistic-update, but on API failure, version conflict, parse error or
* HWPOD offline it must roll back or surface a recoverable error. The frontend
* never edits Markdown files directly or persists to localStorage.
*/
export function useMdtodoTaskMutation() {
const taskMutationLoading = ref(false);
const taskMutationError = ref<string | null>(null);
const taskMutationMessage = ref<string | null>(null);
function clearMessages(): void {
taskMutationError.value = null;
taskMutationMessage.value = null;
}
/**
* Run a mutation action, then refresh the caller-provided reload function and
* return the resulting taskRef so the caller can reselect. On conflict/failure
* the error is surfaced and the caller decides whether to roll back the local
* edit draft.
*/
async function runTaskMutation(
action: () => ReturnType<typeof projectManagementAPI.updateTask>,
preferredTaskRef: string | undefined,
message: string,
onAfterMutate: () => Promise<void>
): Promise<string | null> {
taskMutationLoading.value = true;
taskMutationError.value = null;
taskMutationMessage.value = null;
try {
const response = await action();
if (!response.ok) throw response;
const nextTaskRef = response.data?.task?.taskRef || preferredTaskRef || null;
await onAfterMutate();
taskMutationMessage.value = message;
return nextTaskRef;
} catch (err) {
taskMutationError.value = mutationErrorOf(err).error;
return null;
} finally {
taskMutationLoading.value = false;
}
}
async function updateTask(
taskRef: string,
input: MdtodoTaskMutationInput,
message: string,
onAfterMutate: () => Promise<void>
): Promise<string | null> {
return runTaskMutation(() => projectManagementAPI.updateTask(taskRef, input), taskRef, message, onAfterMutate);
}
async function createTask(
input: MdtodoTaskMutationInput,
message: string,
onAfterMutate: () => Promise<void>
): Promise<string | null> {
return runTaskMutation(() => projectManagementAPI.createTask(input), undefined, message, onAfterMutate);
}
async function deleteTask(
taskRef: string,
input: MdtodoTaskMutationInput,
onAfterMutate: () => Promise<void>
): Promise<string | null> {
return runTaskMutation(() => projectManagementAPI.deleteTask(taskRef, input), undefined, "任务已删除", onAfterMutate);
}
return {
taskMutationLoading,
taskMutationError,
taskMutationMessage,
clearMessages,
runTaskMutation,
updateTask,
createTask,
deleteTask
};
}
@@ -0,0 +1,156 @@
// SPEC: PJ2026-01040409 MDTODO前端重写; draft-2026-06-26-p0-mdtodo-web-rewrite.
// Responsibility: Workbench launch with first-round task context via public Launch API.
import { ref } from "vue";
import { agentAPI, workbenchAPI, type MdtodoTaskLinkRecord, type MdtodoTaskRecord, type ProjectNavigationResponse } from "@/api";
import type { Router } from "vue-router";
export interface WorkbenchLaunchContext {
source: string;
projectId: string;
taskRef: string;
sourceId?: string;
fileRef?: string;
fileName?: string;
taskId?: string;
title?: string;
status?: string;
bodyPreview: string;
reportLinks: { linkId?: string; label?: string; href?: string; relativePath?: string | null }[];
}
/**
* Launches a Workbench session through the public Workbench Launch API and seeds
* it with a first-round user message built from the MDTODO task context. An empty
* session is never treated as success: the launch must return a sessionId and the
* session must project a first user message before navigation. MDTODO and
* Workbench relate only via public API and IDs.
*/
export function useMdtodoWorkbenchLaunch(router: Router) {
const launchLoading = ref(false);
const launchError = ref<string | null>(null);
const launchResult = ref<{ sessionId: string; workbenchUrl: string } | null>(null);
function buildPrompt(task: MdtodoTaskRecord, body: string, links: MdtodoTaskLinkRecord[], fileName: string): string {
const reportLines = links
.filter((link) => link.kind === "markdown-report")
.map((link) => `- ${link.label || link.href}: ${link.relativePath || link.href}`)
.join("\n");
return [
"请基于以下 MDTODO 任务开始工作,先读取相关文件和报告,再给出下一步执行结果。",
"",
`Task: ${task.taskId || task.taskRef} ${task.title || ""}`.trim(),
`TaskRef: ${task.taskRef}`,
`File: ${fileName || task.fileRef || "-"}`,
`Status: ${task.status || "open"}`,
"",
"任务正文:",
body.trim() || "(无正文)",
"",
"相关报告链接:",
reportLines || "(无)"
].join("\n");
}
function buildContext(task: MdtodoTaskRecord, body: string, links: MdtodoTaskLinkRecord[], fileName: string): WorkbenchLaunchContext {
return {
source: "mdtodo-page",
projectId: task.projectId || "",
taskRef: task.taskRef,
sourceId: task.sourceId,
fileRef: task.fileRef,
fileName,
taskId: task.taskId,
title: task.title,
status: task.status,
bodyPreview: body.slice(0, 800),
reportLinks: links.filter((link) => link.kind === "markdown-report").map((link) => ({ linkId: link.linkId, label: link.label, href: link.href, relativePath: link.relativePath }))
};
}
async function waitSessionHasPrompt(sessionId: string): Promise<void> {
for (let attempt = 0; attempt < 5; attempt += 1) {
const response = await workbenchAPI.session(sessionId, 8000);
if (response.ok) {
const session = response.data?.session as { messageCount?: number; messages?: unknown[]; firstUserMessagePreview?: string | null } | undefined;
const count = Number(session?.messageCount ?? (Array.isArray(session?.messages) ? session.messages.length : 0));
if (count > 0 || String(session?.firstUserMessagePreview ?? "").trim()) return;
}
await sleep(800);
}
throw new Error("Workbench session 已创建但未投影 MDTODO prompt");
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => window.setTimeout(resolve, ms));
}
async function launchTask(
task: MdtodoTaskRecord,
body: string,
links: MdtodoTaskLinkRecord[],
fileName: string,
navigation: ProjectNavigationResponse | null
): Promise<{ sessionId: string; workbenchUrl: string } | null> {
if (!task.taskRef || !task.projectId) return null;
const enabled = navigation?.navigation?.capabilities?.workbenchLaunch === true;
if (!enabled) {
launchError.value = "Workbench Launch capability unavailable";
return null;
}
launchLoading.value = true;
launchError.value = null;
try {
const prompt = buildPrompt(task, body, links, fileName);
const launchContext = buildContext(task, body, links, fileName);
const response = await workbenchAPI.launch({
projectId: task.projectId,
taskRef: task.taskRef,
providerProfile: "codex",
launchContext
});
if (!response.ok) throw response;
const sessionId = response.data?.sessionId;
if (!sessionId) throw new Error("Workbench launch 未返回 sessionId");
const chat = await agentAPI.sendAgentMessage({
message: prompt,
prompt,
sessionId,
providerProfile: "codex",
projectId: task.projectId,
taskRef: task.taskRef,
launchContext
}, 120000);
if (!chat.ok) throw chat;
await waitSessionHasPrompt(sessionId);
const result = { sessionId, workbenchUrl: response.data?.workbenchUrl || `/workbench/sessions/${encodeURIComponent(sessionId)}` };
launchResult.value = result;
return result;
} catch (err) {
launchError.value = err && typeof err === "object" && "error" in err ? String((err as { error?: string }).error || "Workbench launch 失败") : "Workbench launch 失败";
return null;
} finally {
launchLoading.value = false;
}
}
async function navigateToWorkbench(result: { sessionId: string; workbenchUrl: string }): Promise<void> {
await router.push(result.workbenchUrl);
}
function isLaunchEnabled(navigation: ProjectNavigationResponse | null): boolean {
return navigation?.navigation?.capabilities?.workbenchLaunch === true;
}
return {
launchLoading,
launchError,
launchResult,
buildPrompt,
buildContext,
launchTask,
navigateToWorkbench,
isLaunchEnabled
};
}
File diff suppressed because it is too large Load Diff