Merge pull request #2323 from pikasTech/fix/2321-mdtodo-refresh-boundary-jd01
fix: MDTODO 左侧任务树保持性刷新
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import LoadingState from "@/components/common/LoadingState.vue";
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
loading: boolean;
|
||||
mode?: "persistent" | "blocking";
|
||||
hasContent?: boolean;
|
||||
label?: string;
|
||||
compact?: boolean;
|
||||
}>(), {
|
||||
mode: "blocking",
|
||||
hasContent: true,
|
||||
label: "加载中",
|
||||
compact: false
|
||||
});
|
||||
|
||||
const showBlockingLoading = computed(() => props.loading && (props.mode === "blocking" || !props.hasContent));
|
||||
const showContent = computed(() => props.hasContent && !showBlockingLoading.value);
|
||||
const showEmpty = computed(() => !props.hasContent && !showBlockingLoading.value);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="refresh-boundary" :data-loading="loading ? 'true' : 'false'" :data-mode="mode" :aria-busy="loading ? 'true' : 'false'">
|
||||
<template v-if="showBlockingLoading">
|
||||
<slot name="loading">
|
||||
<LoadingState :label="label" :compact="compact" />
|
||||
</slot>
|
||||
</template>
|
||||
<template v-else-if="showContent">
|
||||
<slot />
|
||||
</template>
|
||||
<template v-else-if="showEmpty">
|
||||
<slot name="empty" />
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.refresh-boundary { min-width: 0; min-height: 0; }
|
||||
</style>
|
||||
@@ -47,6 +47,14 @@ const workbench = useWorkbenchStore();
|
||||
const selectedSourceId = ref<string | null>(null);
|
||||
const selectedFileRef = ref<string | null>(null);
|
||||
const selectedTaskRef = ref<string | null>(null);
|
||||
type RefreshMode = "persistent" | "blocking";
|
||||
interface LoadFilesAndTasksOptions {
|
||||
fileRef?: string | null;
|
||||
taskId?: string | null;
|
||||
reportLinkId?: string | null;
|
||||
updateRoute?: boolean;
|
||||
refreshMode?: RefreshMode;
|
||||
}
|
||||
|
||||
// Tree / pane view state (local, not URL-authoritative)
|
||||
const taskSearch = ref("");
|
||||
@@ -56,8 +64,10 @@ const taskPaneCollapsed = ref(false);
|
||||
const taskPaneWidth = ref(26);
|
||||
const reportPaneCollapsed = ref(false);
|
||||
const reportPaneWidth = ref(50);
|
||||
const taskTreeRefreshMode = ref<RefreshMode>("blocking");
|
||||
const providerOptionsReady = ref(false);
|
||||
const providerOptionsError = ref<string | null>(null);
|
||||
let taskWindowRefreshSeq = 0;
|
||||
|
||||
// Dialog visibility
|
||||
const showInfo = ref(false);
|
||||
@@ -172,7 +182,7 @@ async function refreshMdtodoProviderOptions(): Promise<void> {
|
||||
|
||||
watch(selectedSourceId, async (sourceId) => {
|
||||
if (!sourceId || loading.value || selection.applyingRouteSelection.value) return;
|
||||
await loadFilesAndTasks(sourceId, { updateRoute: true });
|
||||
await loadFilesAndTasks(sourceId, { updateRoute: true, refreshMode: "blocking" });
|
||||
});
|
||||
|
||||
watch(selectedTaskRef, async (taskRef) => {
|
||||
@@ -190,6 +200,21 @@ watch(taskData.taskDetail, (detail) => {
|
||||
if (detail?.taskRef === selectedTaskRef.value && !editingBody.value) editBody.value = detail.body || "";
|
||||
});
|
||||
|
||||
function beginTaskWindowRefresh(mode: RefreshMode): number {
|
||||
const refreshSeq = ++taskWindowRefreshSeq;
|
||||
taskTreeRefreshMode.value = mode;
|
||||
taskData.taskLoading.value = true;
|
||||
return refreshSeq;
|
||||
}
|
||||
|
||||
function isCurrentTaskWindowRefresh(refreshSeq: number): boolean {
|
||||
return refreshSeq === taskWindowRefreshSeq;
|
||||
}
|
||||
|
||||
function finishTaskWindowRefresh(refreshSeq: number): void {
|
||||
if (isCurrentTaskWindowRefresh(refreshSeq)) taskData.taskLoading.value = false;
|
||||
}
|
||||
|
||||
async function loadPage(): Promise<void> {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
@@ -203,7 +228,7 @@ async function loadPage(): Promise<void> {
|
||||
: 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));
|
||||
if (selectedSourceId.value) await loadFilesAndTasks(selectedSourceId.value, { ...selection.routeSelectionOptions(true), refreshMode: "blocking" });
|
||||
else await taskData.loadLinks(null);
|
||||
} catch (err) {
|
||||
setError(err);
|
||||
@@ -212,30 +237,34 @@ async function loadPage(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadFilesAndTasks(sourceId: string, options: { fileRef?: string | null; taskId?: string | null; reportLinkId?: string | null; updateRoute?: boolean } = {}): Promise<void> {
|
||||
taskData.taskLoading.value = true;
|
||||
async function loadFilesAndTasks(sourceId: string, options: LoadFilesAndTasksOptions = {}): Promise<void> {
|
||||
const refreshMode = options.refreshMode ?? "blocking";
|
||||
const refreshSeq = beginTaskWindowRefresh(refreshMode);
|
||||
try {
|
||||
await taskData.loadFiles(sourceId);
|
||||
if (!isCurrentTaskWindowRefresh(refreshSeq)) return;
|
||||
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);
|
||||
if (!isCurrentTaskWindowRefresh(refreshSeq)) return;
|
||||
const preferred = taskData.resolvePreferredTask(options.taskId ?? selectedTaskRef.value);
|
||||
selectedTaskRef.value = preferred?.taskRef ?? taskData.tasks.value[0]?.taskRef ?? null;
|
||||
collapsedTaskRefs.value = new Set();
|
||||
if (refreshMode === "blocking") 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)]);
|
||||
if (!isCurrentTaskWindowRefresh(refreshSeq)) return;
|
||||
await report.openReportPreviewById(selectedTaskRef.value, selectedTaskLinks.value, options.reportLinkId);
|
||||
} else if (options.updateRoute !== false) {
|
||||
void syncRoute({ replace: true });
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err);
|
||||
if (isCurrentTaskWindowRefresh(refreshSeq)) setError(err);
|
||||
} finally {
|
||||
taskData.taskLoading.value = false;
|
||||
finishTaskWindowRefresh(refreshSeq);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -243,9 +272,10 @@ async function selectFile(fileRef: string): Promise<void> {
|
||||
selectedFileRef.value = fileRef || null;
|
||||
report.closeReportPreview();
|
||||
if (!selectedSourceId.value) return;
|
||||
taskData.taskLoading.value = true;
|
||||
const refreshSeq = beginTaskWindowRefresh("blocking");
|
||||
try {
|
||||
await taskData.loadTaskWindow(selectedSourceId.value, selectedFileRef.value, null);
|
||||
if (!isCurrentTaskWindowRefresh(refreshSeq)) return;
|
||||
selectedTaskRef.value = taskData.tasks.value[0]?.taskRef ?? null;
|
||||
collapsedTaskRefs.value = new Set();
|
||||
if (!selectedTaskRef.value) await Promise.all([taskData.loadLinks(null), taskData.loadTaskDetail(null)]);
|
||||
@@ -253,7 +283,7 @@ async function selectFile(fileRef: string): Promise<void> {
|
||||
} catch (err) {
|
||||
setError(err);
|
||||
} finally {
|
||||
taskData.taskLoading.value = false;
|
||||
finishTaskWindowRefresh(refreshSeq);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -279,8 +309,25 @@ async function applyRouteSelection(): Promise<void> {
|
||||
if (!nextSourceId) return;
|
||||
selection.applyingRouteSelection.value = true;
|
||||
try {
|
||||
const sourceChanged = nextSourceId !== selectedSourceId.value;
|
||||
const routeFileChanged = Boolean(sel.fileRef && sel.fileRef !== selectedFileRef.value);
|
||||
const missingTaskWindow = !taskData.files.value.length || !taskData.tasks.value.length;
|
||||
selectedSourceId.value = nextSourceId;
|
||||
await loadFilesAndTasks(nextSourceId, selection.routeSelectionOptions(false));
|
||||
if (sourceChanged || routeFileChanged || missingTaskWindow) {
|
||||
await loadFilesAndTasks(nextSourceId, { ...selection.routeSelectionOptions(false), refreshMode: "blocking" });
|
||||
return;
|
||||
}
|
||||
|
||||
const preferred = sel.taskId ? taskData.resolvePreferredTask(sel.taskId) : selectedTask.value;
|
||||
if (sel.taskId && !preferred) {
|
||||
await loadFilesAndTasks(nextSourceId, { ...selection.routeSelectionOptions(false), refreshMode: taskData.tasks.value.length ? "persistent" : "blocking" });
|
||||
return;
|
||||
}
|
||||
selectedTaskRef.value = preferred?.taskRef ?? selectedTaskRef.value ?? taskData.tasks.value[0]?.taskRef ?? null;
|
||||
if (sel.linkId && selectedTaskRef.value) {
|
||||
await Promise.all([taskData.loadLinks(selectedTaskRef.value), taskData.loadTaskDetail(selectedTaskRef.value)]);
|
||||
await report.openReportPreviewById(selectedTaskRef.value, selectedTaskLinks.value, sel.linkId);
|
||||
}
|
||||
} finally {
|
||||
selection.applyingRouteSelection.value = false;
|
||||
}
|
||||
@@ -302,8 +349,14 @@ function taskFingerprint(): string | 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);
|
||||
const refreshSeq = beginTaskWindowRefresh("persistent");
|
||||
try {
|
||||
await taskData.loadFiles(sourceId);
|
||||
if (!isCurrentTaskWindowRefresh(refreshSeq)) return;
|
||||
await taskData.loadTaskWindow(sourceId, selectedFileRef.value, selectedTaskRef.value);
|
||||
} finally {
|
||||
finishTaskWindowRefresh(refreshSeq);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveTaskBasics(): Promise<void> {
|
||||
@@ -414,7 +467,14 @@ async function reindexSource(): Promise<void> {
|
||||
const sourceId = selectedSourceId.value;
|
||||
if (!sourceId) return;
|
||||
const result = await source.reindexSource(sourceId);
|
||||
if (result) await loadFilesAndTasks(sourceId);
|
||||
if (result) {
|
||||
await loadFilesAndTasks(sourceId, {
|
||||
fileRef: selectedFileRef.value,
|
||||
taskId: selectedTaskRef.value,
|
||||
updateRoute: true,
|
||||
refreshMode: taskData.tasks.value.length ? "persistent" : "blocking"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function setError(err: unknown): void {
|
||||
@@ -444,7 +504,7 @@ function setError(err: unknown): void {
|
||||
:selected-source-id="selectedSourceId"
|
||||
:files="taskData.files.value"
|
||||
:selected-file-ref="selectedFileRef"
|
||||
:file-loading="taskData.taskLoading.value"
|
||||
:file-loading="taskData.taskLoading.value && taskTreeRefreshMode === 'blocking'"
|
||||
:reindex-loading="source.sourceReindexLoading.value"
|
||||
@update:selected-source-id="selectedSourceId = $event"
|
||||
@update:selected-file-ref="selectFile($event)"
|
||||
@@ -484,6 +544,7 @@ function setError(err: unknown): void {
|
||||
:collapsed-task-refs="collapsedTaskRefs"
|
||||
:collapsed="taskPaneCollapsed"
|
||||
:loading="taskData.taskLoading.value"
|
||||
:refresh-mode="taskTreeRefreshMode"
|
||||
:search="taskSearch"
|
||||
:status-filter="taskStatusFilter"
|
||||
@update:search="taskSearch = $event"
|
||||
|
||||
@@ -7,9 +7,9 @@ import type { MdtodoTaskDetailRecord, MdtodoTaskLinkRecord, MdtodoTaskRecord, Pr
|
||||
import type { ProviderProfile } from "@/types";
|
||||
import type { ApiError, ErrorDiagnostic } from "@/types";
|
||||
import type { ProviderProfileOption } from "@/stores/workbench-session";
|
||||
import LoadingState from "@/components/common/LoadingState.vue";
|
||||
import EmptyState from "@/components/common/EmptyState.vue";
|
||||
import ApiErrorDiagnostic from "@/components/common/ApiErrorDiagnostic.vue";
|
||||
import RefreshBoundary from "@/components/common/RefreshBoundary.vue";
|
||||
|
||||
const props = defineProps<{
|
||||
task: MdtodoTaskRecord | null;
|
||||
@@ -112,11 +112,8 @@ function normalizeTaskText(value?: string | null): string {
|
||||
<p v-else-if="launchBlocker" class="launch-blocker" data-testid="mdtodo-workbench-launch-blocker">{{ launchBlocker }}</p>
|
||||
</div>
|
||||
|
||||
<section class="task-detail-content" data-testid="mdtodo-task-editor">
|
||||
<div class="task-detail-status">
|
||||
<LoadingState v-if="detailLoading" compact label="同步任务详情" />
|
||||
<p v-if="detailError" class="source-error" data-testid="mdtodo-task-detail-error">{{ detailError }}</p>
|
||||
</div>
|
||||
<RefreshBoundary class="task-detail-content" data-testid="mdtodo-task-editor" :loading="detailLoading" mode="blocking" :has-content="true" compact label="同步任务详情">
|
||||
<p v-if="detailError" class="source-error task-detail-error" data-testid="mdtodo-task-detail-error">{{ detailError }}</p>
|
||||
|
||||
<section class="task-body-section" data-testid="mdtodo-task-body">
|
||||
<div v-if="editingBody" class="inline-body-editor">
|
||||
@@ -131,7 +128,7 @@ function normalizeTaskText(value?: string | null): string {
|
||||
<p v-else class="empty-inline">暂无补充正文</p>
|
||||
</article>
|
||||
</section>
|
||||
</section>
|
||||
</RefreshBoundary>
|
||||
|
||||
<footer class="task-document-footer">
|
||||
<section class="report-link-section" data-testid="mdtodo-report-section">
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
<!-- Responsibility: Rxx outline tree — fold, search, status filter, current highlight, deep-link auto-expand. -->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import type { MdtodoTaskPage, MdtodoTaskRecord } from "@/api";
|
||||
import LoadingState from "@/components/common/LoadingState.vue";
|
||||
import EmptyState from "@/components/common/EmptyState.vue";
|
||||
import RefreshBoundary from "@/components/common/RefreshBoundary.vue";
|
||||
import { statusLabel, taskIndent as indentStyle, useRxxTaskTree } from "@/composables/mdtodo/useMdtodoDisplay";
|
||||
|
||||
const props = defineProps<{
|
||||
const props = withDefaults(defineProps<{
|
||||
tasks: MdtodoTaskRecord[];
|
||||
fileScopedTaskCount: number;
|
||||
taskPage: MdtodoTaskPage | null;
|
||||
@@ -15,9 +16,12 @@ const props = defineProps<{
|
||||
collapsedTaskRefs: Set<string>;
|
||||
collapsed: boolean;
|
||||
loading: boolean;
|
||||
refreshMode?: "persistent" | "blocking";
|
||||
search: string;
|
||||
statusFilter: string;
|
||||
}>();
|
||||
}>(), {
|
||||
refreshMode: "blocking"
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
"update:search": [value: string];
|
||||
@@ -33,6 +37,10 @@ const tree = useRxxTaskTree(
|
||||
() => props.collapsedTaskRefs
|
||||
);
|
||||
|
||||
const visibleTaskRows = computed(() => tree.visibleTaskRows.value);
|
||||
const hasVisibleTasks = computed(() => visibleTaskRows.value.length > 0);
|
||||
const showHeaderRefresh = computed(() => props.loading && props.refreshMode === "persistent" && hasVisibleTasks.value);
|
||||
|
||||
function toggleTask(task: MdtodoTaskRecord): void {
|
||||
const next = new Set(props.collapsedTaskRefs);
|
||||
if (next.has(task.taskRef)) next.delete(task.taskRef);
|
||||
@@ -46,9 +54,13 @@ function toggleTask(task: MdtodoTaskRecord): void {
|
||||
<header class="mdtodo-panel-header">
|
||||
<div class="task-tree-title">
|
||||
<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>
|
||||
<p v-if="!collapsed">{{ visibleTaskRows.length }} / {{ taskPage?.total ?? fileScopedTaskCount }} tasks<span v-if="taskPage?.hasMore"> · window {{ taskPage.limit }}</span></p>
|
||||
</div>
|
||||
<div class="task-pane-actions">
|
||||
<span v-if="showHeaderRefresh" class="task-refresh-indicator" role="status" aria-live="polite" data-testid="mdtodo-task-tree-refreshing">
|
||||
<span class="task-refresh-dot" aria-hidden="true" />
|
||||
<span v-if="!collapsed">同步中</span>
|
||||
</span>
|
||||
<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>
|
||||
@@ -64,18 +76,21 @@ function toggleTask(task: MdtodoTaskRecord): void {
|
||||
<option value="blocked">阻塞</option>
|
||||
</select>
|
||||
</div>
|
||||
<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>
|
||||
<RefreshBoundary class="task-tree-boundary" :loading="loading" :mode="refreshMode" :has-content="hasVisibleTasks" compact label="同步文件和任务">
|
||||
<template #empty>
|
||||
<EmptyState title="暂无任务" description="当前文件没有任务投影。" />
|
||||
</template>
|
||||
<div class="task-tree" role="tree" aria-label="MDTODO task tree" :aria-busy="loading ? 'true' : 'false'" :data-refreshing="loading ? 'true' : 'false'">
|
||||
<div v-for="task in visibleTaskRows" :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>
|
||||
</div>
|
||||
</RefreshBoundary>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
@@ -88,9 +103,13 @@ function toggleTask(task: MdtodoTaskRecord): void {
|
||||
.mdtodo-panel-header h2 { min-width: 0; margin: 0; overflow: hidden; color: #111827; font-size: 14px; line-height: 1.2; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.mdtodo-panel-header p { margin: 2px 0 0; color: #64706b; font-size: 11px; }
|
||||
.task-pane-actions { display: flex; gap: 6px; margin-left: auto; }
|
||||
.task-refresh-indicator { display: inline-flex; min-width: 0; height: 30px; align-items: center; gap: 5px; color: #0f766e; font-size: 11px; font-weight: 800; white-space: nowrap; }
|
||||
.task-refresh-dot { width: 13px; height: 13px; border: 2px solid #b7e4df; border-top-color: #0f766e; border-radius: 999px; animation: task-refresh-spin 0.8s linear infinite; }
|
||||
.task-tools { display: grid; min-width: 0; grid-template-columns: minmax(0, 1fr) minmax(96px, 0.55fr); gap: 6px; }
|
||||
.task-tools input, .task-tools select { width: 100%; min-width: 0; height: 30px; border: 1px solid #cbd6dd; border-radius: 6px; background: #fff; color: #111827; padding: 0 8px; font: inherit; font-size: 12px; }
|
||||
.task-tree-boundary { display: grid; min-height: 0; }
|
||||
.task-tree { display: grid; min-height: 0; max-height: min(54dvh, 560px); align-content: start; gap: 1px; overflow: auto; border-top: 1px solid #e3e9ee; padding-top: 2px; }
|
||||
.task-tree[data-refreshing="true"] { border-top-color: #b7e4df; }
|
||||
.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 transparent; border-radius: 4px; background: transparent; color: #52616b; font-weight: 850; line-height: 1; }
|
||||
.task-toggle:hover:not(:disabled) { border-color: #cbd6dd; background: #fff; }
|
||||
@@ -106,6 +125,10 @@ function toggleTask(task: MdtodoTaskRecord): void {
|
||||
.task-status[data-status="blocked"] { border-color: #fed7aa; background: #fff7ed; color: #9a3412; }
|
||||
.icon-button { display: inline-grid; width: 30px; height: 30px; place-items: center; border: 1px solid #cbd6dd; border-radius: 6px; background: #fff; color: #111827; font-weight: 850; }
|
||||
.icon-button:disabled { opacity: 0.5; }
|
||||
@keyframes task-refresh-spin { to { transform: rotate(360deg); } }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.task-refresh-dot { animation: none; }
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.mdtodo-task-panel { height: 100%; align-content: stretch; grid-template-rows: auto auto minmax(0, 1fr); }
|
||||
.task-tree { max-height: none; }
|
||||
|
||||
@@ -23,18 +23,27 @@ export function useMdtodoTaskData() {
|
||||
const linkLoading = ref(false);
|
||||
const taskDetailError = ref<string | null>(null);
|
||||
|
||||
let filesRequestSeq = 0;
|
||||
let taskWindowRequestSeq = 0;
|
||||
let taskDetailRequestSeq = 0;
|
||||
let linksRequestSeq = 0;
|
||||
|
||||
const taskByRef = computed(() => new Map(tasks.value.map((task) => [task.taskRef, task])));
|
||||
|
||||
async function loadFiles(sourceId: string): Promise<MdtodoFileRecord[]> {
|
||||
const requestSeq = ++filesRequestSeq;
|
||||
const fileResponse = await projectManagementAPI.files(sourceId);
|
||||
if (!fileResponse.ok) throw fileResponse;
|
||||
if (requestSeq !== filesRequestSeq) return files.value;
|
||||
files.value = fileResponse.data?.files ?? [];
|
||||
return files.value;
|
||||
}
|
||||
|
||||
async function loadTaskWindow(sourceId: string, fileRef: string | null, preferredTaskRef?: string | null): Promise<MdtodoTaskRecord[]> {
|
||||
const requestSeq = ++taskWindowRequestSeq;
|
||||
const taskResponse = await projectManagementAPI.tasks({ sourceId, fileRef, limit: TASK_WINDOW_LIMIT });
|
||||
if (!taskResponse.ok) throw taskResponse;
|
||||
if (requestSeq !== taskWindowRequestSeq) return tasks.value;
|
||||
tasks.value = taskResponse.data?.tasks ?? [];
|
||||
taskPage.value = taskResponse.data?.page ?? null;
|
||||
return tasks.value;
|
||||
@@ -46,34 +55,41 @@ export function useMdtodoTaskData() {
|
||||
}
|
||||
|
||||
async function loadTaskDetail(taskRef: string | null): Promise<void> {
|
||||
const requestSeq = ++taskDetailRequestSeq;
|
||||
taskDetailError.value = null;
|
||||
if (!taskRef) {
|
||||
taskDetail.value = null;
|
||||
taskDetailLoading.value = false;
|
||||
return;
|
||||
}
|
||||
taskDetailLoading.value = true;
|
||||
try {
|
||||
const response = await projectManagementAPI.taskDetail(taskRef);
|
||||
if (!response.ok) throw response;
|
||||
if (requestSeq !== taskDetailRequestSeq) return;
|
||||
taskDetail.value = response.data?.detail ?? null;
|
||||
} catch (err) {
|
||||
if (requestSeq !== taskDetailRequestSeq) return;
|
||||
taskDetail.value = null;
|
||||
taskDetailError.value = err && typeof err === "object" && "error" in err ? String((err as { error?: string }).error || "任务详情加载失败") : "任务详情加载失败";
|
||||
} finally {
|
||||
taskDetailLoading.value = false;
|
||||
if (requestSeq === taskDetailRequestSeq) taskDetailLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadLinks(taskRef: string | null): Promise<void> {
|
||||
const requestSeq = ++linksRequestSeq;
|
||||
linkLoading.value = true;
|
||||
try {
|
||||
const response = await projectManagementAPI.workbenchLinks(taskRef ? { taskRef } : {});
|
||||
if (!response.ok) throw response;
|
||||
if (requestSeq !== linksRequestSeq) return;
|
||||
links.value = response.data?.links ?? [];
|
||||
} catch {
|
||||
if (requestSeq !== linksRequestSeq) return;
|
||||
links.value = [];
|
||||
} finally {
|
||||
linkLoading.value = false;
|
||||
if (requestSeq === linksRequestSeq) linkLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user