Merge pull request #2323 from pikasTech/fix/2321-mdtodo-refresh-boundary-jd01

fix: MDTODO 左侧任务树保持性刷新
This commit is contained in:
Lyon
2026-07-01 14:38:31 +08:00
committed by GitHub
5 changed files with 176 additions and 38 deletions
@@ -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;
}
}