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:
@@ -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
Reference in New Issue
Block a user