Merge pull request #2521 from pikasTech/fix/2517-v03-acceptance
Pipelines as Code CI / hwlab-nc01-v03-ci-poll- Success
Pipelines as Code CI / hwlab-nc01-v03-ci-poll- Success
fix(web): 收敛 MDTODO 离线详情与布局
This commit is contained in:
@@ -6,8 +6,39 @@ import { expect, test } from "bun:test";
|
||||
|
||||
import { executeHwpodNodeOpsPlan } from "../../tools/src/hwpod-node-lib.ts";
|
||||
import { createProjectManagementApp } from "./server.ts";
|
||||
import { createMdtodoSourceAdapter, normalizeProjectSourceInput } from "./source-adapter.ts";
|
||||
import { createProjectManagementStore } from "./store.ts";
|
||||
|
||||
test("project management source adapter types HWPOD transport failures", async () => {
|
||||
const normalized = normalizeProjectSourceInput({
|
||||
sourceId: "transport-hwpod-mdtodo",
|
||||
sourceKind: "hwpod-workspace",
|
||||
projectId: "project_transport",
|
||||
hwpodId: "transport-hwpod",
|
||||
nodeId: "transport-node",
|
||||
workspaceRootRef: "C:/Work/Transport",
|
||||
mdtodoRootRef: "docs/MDTODO"
|
||||
});
|
||||
expect(normalized.ok).toBe(true);
|
||||
const adapter = createMdtodoSourceAdapter({
|
||||
env: { HWLAB_PROJECT_MANAGEMENT_HWPOD_NODE_OPS_URL: "http://hwpod.invalid/node-ops" },
|
||||
fetchImpl: async () => { throw new TypeError("connection refused with internal endpoint"); }
|
||||
});
|
||||
let caught;
|
||||
try {
|
||||
await adapter.readFile(normalized.source, "MDTODO.md");
|
||||
} catch (error) {
|
||||
caught = error;
|
||||
}
|
||||
expect(caught).toMatchObject({
|
||||
code: "hwpod_transport_unavailable",
|
||||
statusCode: 502,
|
||||
sourceKind: "hwpod-workspace",
|
||||
message: "HWPOD node-ops transport is unavailable"
|
||||
});
|
||||
expect(String(caught?.message)).not.toContain("internal endpoint");
|
||||
});
|
||||
|
||||
test("project management app exposes MDTODO read model and Workbench link writes", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "hwlab-project-management-"));
|
||||
try {
|
||||
@@ -348,6 +379,85 @@ test("project management app bootstraps HWPOD sources from a mounted JSON file",
|
||||
}
|
||||
});
|
||||
|
||||
test("project management task detail falls back to the last HWPOD projection when the node is offline", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "hwlab-project-management-hwpod-partial-"));
|
||||
const defaultRoot = join(root, "default");
|
||||
const configPath = join(root, "sources.json");
|
||||
let hwpodState = "ready";
|
||||
try {
|
||||
await mkdir(defaultRoot, { recursive: true });
|
||||
await writeFile(join(defaultRoot, "MDTODO.md"), "# Default\n\n## R1 Default\n", "utf8");
|
||||
await writeFile(configPath, JSON.stringify({
|
||||
sources: [{
|
||||
sourceId: "offline-hwpod-mdtodo",
|
||||
sourceKind: "hwpod-workspace",
|
||||
displayName: "Offline HWPOD MDTODO",
|
||||
projectId: "project_hwpod_partial",
|
||||
hwpodId: "offline-hwpod",
|
||||
nodeId: "node-offline",
|
||||
workspaceRootRef: "F:/Work/Offline",
|
||||
mdtodoRootRef: "docs/MDTODO"
|
||||
}]
|
||||
}), "utf8");
|
||||
const app = createProjectManagementApp({
|
||||
env: { HWLAB_PROJECT_MANAGEMENT_BOOTSTRAP_SOURCES_PATH: configPath },
|
||||
store: createProjectManagementStore({ kind: "memory" }),
|
||||
sourceRoot: defaultRoot,
|
||||
sourceId: "default-mdtodo",
|
||||
projectId: "project_test",
|
||||
hwpodNodeOpsHandler: (plan) => {
|
||||
if (hwpodState === "bug") throw new Error("programmer bug with internal detail");
|
||||
if (hwpodState === "offline") {
|
||||
const blocker = { code: "hwpod_node_offline", summary: "node-offline is not connected" };
|
||||
return { ok: false, status: "blocked", blocker, results: [{ opId: "op_01", op: plan.intent, ok: false, status: "blocked", blocker }] };
|
||||
}
|
||||
const output = plan.intent === "workspace.ls"
|
||||
? { entries: [{ name: "offline.md", type: "file" }] }
|
||||
: { content: "# Offline\n\n## R1 Projected task [in_progress]\n\nProjected body. See [report](./reports/projected.md).\n" };
|
||||
return { ok: true, status: "completed", results: [{ opId: "op_01", op: plan.intent, ok: true, status: "completed", output }] };
|
||||
}
|
||||
});
|
||||
|
||||
expect((await app.fetch(new Request("http://service/health/ready"))).status).toBe(200);
|
||||
const files = await json(app, "/v1/project-management/mdtodo/files?sourceId=offline-hwpod-mdtodo");
|
||||
const tasks = await json(app, `/v1/project-management/mdtodo/tasks?sourceId=offline-hwpod-mdtodo&fileRef=${encodeURIComponent(files.files[0].fileRef)}`);
|
||||
hwpodState = "offline";
|
||||
|
||||
const response = await app.fetch(new Request(`http://service/v1/project-management/mdtodo/task-detail?taskRef=${encodeURIComponent(tasks.tasks[0].taskRef)}`));
|
||||
expect(response.status).toBe(200);
|
||||
const payload = await response.json();
|
||||
expect(payload.status).toBe("partial");
|
||||
expect(payload.task).toMatchObject({ taskId: "R1", title: "Projected task" });
|
||||
expect(payload.task.bodyPreview).toContain("Projected body.");
|
||||
expect(payload.detail).toBeNull();
|
||||
expect(payload.file).toMatchObject({ fileRef: files.files[0].fileRef, taskCount: 1 });
|
||||
expect(payload.detailAuthority).toMatchObject({
|
||||
state: "unavailable",
|
||||
authority: "project-management-projection",
|
||||
sourceKind: "hwpod-workspace",
|
||||
code: "hwpod_node_offline",
|
||||
retryable: true,
|
||||
valuesRedacted: true
|
||||
});
|
||||
expect(JSON.stringify(payload)).not.toContain("node-offline is not connected");
|
||||
|
||||
const projectedLink = tasks.tasks[0].links[0];
|
||||
const reportResponse = await app.fetch(new Request(`http://service/v1/project-management/mdtodo/report-preview?taskRef=${encodeURIComponent(tasks.tasks[0].taskRef)}&linkId=${encodeURIComponent(projectedLink.linkId)}`));
|
||||
expect(reportResponse.status).toBe(503);
|
||||
const reportPayload = await reportResponse.json();
|
||||
expect(reportPayload).toMatchObject({ error: { code: "hwpod_node_offline", message: "HWPOD 源当前不可读;报告预览需等待 Node 恢复。", status: 503 } });
|
||||
expect(JSON.stringify(reportPayload)).not.toContain("node-offline is not connected");
|
||||
|
||||
hwpodState = "bug";
|
||||
const programmerError = await app.fetch(new Request(`http://service/v1/project-management/mdtodo/task-detail?taskRef=${encodeURIComponent(tasks.tasks[0].taskRef)}`));
|
||||
expect(programmerError.status).toBe(500);
|
||||
expect(await programmerError.json()).toMatchObject({ error: { code: "project_management_error", status: 500 } });
|
||||
await app.close();
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("project management HWPOD source adapter authenticates node-ops requests from configured env", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "hwlab-project-management-hwpod-auth-"));
|
||||
const defaultRoot = join(root, "default");
|
||||
|
||||
@@ -390,12 +390,23 @@ async function handleReadMdtodoFile(url, store, sourceAdapter, fileRef) {
|
||||
async function handleReadMdtodoTaskDetail(url, store, sourceAdapter) {
|
||||
const taskRef = queryOpaque(url, "taskRef");
|
||||
if (!taskRef) return jsonError(400, "task_ref_required", "taskRef is required");
|
||||
const detail = await loadTaskDetail({ store, sourceAdapter, taskRef });
|
||||
const detail = await loadTaskDetail({ store, sourceAdapter, taskRef, allowProjectedFallback: true });
|
||||
if (detail.error) return detail.error;
|
||||
if (detail.availability?.state === "unavailable") {
|
||||
return json(200, envelope({
|
||||
status: "partial",
|
||||
task: detail.task,
|
||||
detail: null,
|
||||
file: fileSummary(detail.document),
|
||||
detailAuthority: detail.availability
|
||||
}));
|
||||
}
|
||||
return json(200, envelope({
|
||||
status: "ready",
|
||||
task: { ...detail.task, bodyPreview: detail.detail.bodyPreview, linkCount: detail.detail.links.length },
|
||||
detail: detail.detail,
|
||||
file: fileSummary(detail.file)
|
||||
file: fileSummary(detail.file),
|
||||
detailAuthority: taskDetailAuthority("ready", detail.source)
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -417,12 +428,24 @@ async function handleReadMdtodoReportPreview(url, store, sourceAdapter) {
|
||||
const linkId = queryToken(url, "linkId");
|
||||
if (!taskRef) return jsonError(400, "task_ref_required", "taskRef is required");
|
||||
if (!linkId) return jsonError(400, "link_id_required", "linkId is required");
|
||||
const detail = await loadTaskDetail({ store, sourceAdapter, taskRef });
|
||||
let detail;
|
||||
try {
|
||||
detail = await loadTaskDetail({ store, sourceAdapter, taskRef });
|
||||
} catch (error) {
|
||||
if (isHwpodAvailabilityError(error)) return hwpodAvailabilityError(error);
|
||||
throw error;
|
||||
}
|
||||
if (detail.error) return detail.error;
|
||||
const link = detail.detail.links.find((item) => item.linkId === linkId);
|
||||
if (!link) return jsonError(404, "report_link_not_found", "MDTODO report link was not found");
|
||||
if (link.kind !== "markdown-report" || !link.relativePath) return jsonError(400, "unsupported_report_link", "Only safe relative Markdown report links can be previewed");
|
||||
const report = await sourceAdapter.readLinkedMarkdown(detail.source, detail.document.relativePath, link.href);
|
||||
let report;
|
||||
try {
|
||||
report = await sourceAdapter.readLinkedMarkdown(detail.source, detail.document.relativePath, link.href);
|
||||
} catch (error) {
|
||||
if (isHwpodAvailabilityError(error)) return hwpodAvailabilityError(error);
|
||||
throw error;
|
||||
}
|
||||
return json(200, envelope({
|
||||
task: detail.task,
|
||||
link,
|
||||
@@ -437,12 +460,27 @@ async function handleReadMdtodoReportPreview(url, store, sourceAdapter) {
|
||||
}));
|
||||
}
|
||||
|
||||
async function loadTaskDetail({ store, sourceAdapter, taskRef }) {
|
||||
async function loadTaskDetail({ store, sourceAdapter, taskRef, allowProjectedFallback = false }) {
|
||||
const task = await getTaskByRef(store, taskRef);
|
||||
if (!task) return { error: jsonError(404, "task_not_found", "MDTODO task was not found") };
|
||||
const { source, document, error } = await resolveMdtodoDocument(store, task.sourceId, task.fileRef);
|
||||
if (error) return { error };
|
||||
const file = await sourceAdapter.readFile(source, document.relativePath);
|
||||
let file;
|
||||
try {
|
||||
file = await sourceAdapter.readFile(source, document.relativePath);
|
||||
} catch (readError) {
|
||||
if (allowProjectedFallback && source.sourceKind === "hwpod-workspace" && isHwpodAvailabilityError(readError)) {
|
||||
return {
|
||||
source,
|
||||
document,
|
||||
file: null,
|
||||
task,
|
||||
detail: null,
|
||||
availability: taskDetailAuthority("unavailable", source, readError)
|
||||
};
|
||||
}
|
||||
throw readError;
|
||||
}
|
||||
const detail = readMdtodoTaskDetail(file.content, {
|
||||
sourceId: source.sourceId,
|
||||
fileRef: document.fileRef,
|
||||
@@ -455,6 +493,34 @@ async function loadTaskDetail({ store, sourceAdapter, taskRef }) {
|
||||
return { source, document, file, task, detail };
|
||||
}
|
||||
|
||||
function isHwpodAvailabilityError(error) {
|
||||
const statusCode = Number(error?.statusCode);
|
||||
return error?.sourceKind === "hwpod-workspace" && [502, 503, 504].includes(statusCode);
|
||||
}
|
||||
|
||||
function hwpodAvailabilityError(error) {
|
||||
return jsonError(
|
||||
Number(error?.statusCode),
|
||||
safeToken(error?.code, "hwpod_source_unavailable"),
|
||||
"HWPOD 源当前不可读;报告预览需等待 Node 恢复。"
|
||||
);
|
||||
}
|
||||
|
||||
function taskDetailAuthority(state, source, error = null) {
|
||||
const unavailable = state === "unavailable";
|
||||
return {
|
||||
state,
|
||||
authority: unavailable ? "project-management-projection" : "source-markdown",
|
||||
sourceKind: source?.sourceKind ?? null,
|
||||
code: unavailable ? safeToken(error?.code, "hwpod_source_unavailable") : null,
|
||||
message: unavailable
|
||||
? "HWPOD 源当前不可读;页面继续展示最近一次项目投影,正文编辑和报告预览需等待 Node 恢复。"
|
||||
: null,
|
||||
retryable: unavailable,
|
||||
valuesRedacted: true
|
||||
};
|
||||
}
|
||||
|
||||
async function handleWriteMdtodoFile(request, url, store, sourceAdapter, fileRef, actor) {
|
||||
const body = await readJsonObject(request, 1024 * 1024 + 4096);
|
||||
if (!body.ok) return jsonError(body.code === "body_too_large" ? 413 : 400, body.code, body.message);
|
||||
|
||||
@@ -128,13 +128,26 @@ function createHwpodNodeOpsHttpClient({ env, fetchImpl }) {
|
||||
return async (plan) => {
|
||||
const headers = { "content-type": "application/json", "x-source-service-id": "hwlab-project-management" };
|
||||
if (apiKey) headers.authorization = `Bearer ${apiKey}`;
|
||||
const response = await fetchImpl(endpoint, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(plan),
|
||||
signal: AbortSignal.timeout(30000)
|
||||
});
|
||||
return response.json();
|
||||
let response;
|
||||
try {
|
||||
response = await fetchImpl(endpoint, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(plan),
|
||||
signal: AbortSignal.timeout(30000)
|
||||
});
|
||||
} catch {
|
||||
throw sourceError("hwpod_transport_unavailable", "HWPOD node-ops transport is unavailable", 502, {
|
||||
sourceKind: "hwpod-workspace"
|
||||
});
|
||||
}
|
||||
try {
|
||||
return await response.json();
|
||||
} catch {
|
||||
throw sourceError("hwpod_invalid_response", "HWPOD node-ops returned an invalid response", 502, {
|
||||
sourceKind: "hwpod-workspace"
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -167,7 +180,11 @@ async function listHwpodMarkdownFiles(hwpodNodeOpsHandler, source) {
|
||||
}
|
||||
|
||||
async function runHwpodOp(hwpodNodeOpsHandler, source, op, args) {
|
||||
if (!hwpodNodeOpsHandler) throw sourceError("hwpod_adapter_unconfigured", "HWPOD node-ops adapter is not configured", 503);
|
||||
if (!hwpodNodeOpsHandler) {
|
||||
throw sourceError("hwpod_adapter_unconfigured", "HWPOD node-ops adapter is not configured", 503, {
|
||||
sourceKind: "hwpod-workspace"
|
||||
});
|
||||
}
|
||||
const plan = {
|
||||
contractVersion: HWPOD_NODE_OPS_CONTRACT_VERSION,
|
||||
planId: `hwpod_plan_${randomUUID()}`,
|
||||
@@ -184,6 +201,7 @@ async function runHwpodOp(hwpodNodeOpsHandler, source, op, args) {
|
||||
const summary = String(blocker.summary ?? blocker.message ?? "HWPOD node-ops request failed");
|
||||
const traceLine = otelTraceId ? `OTel traceId: ${otelTraceId}` : null;
|
||||
throw sourceError(blocker.code ?? "hwpod_node_ops_failed", traceLine ? `${summary}\n${traceLine}` : summary, payload?.status === "blocked" ? 503 : 502, {
|
||||
sourceKind: "hwpod-workspace",
|
||||
hwpodStatus: payload?.status ?? null,
|
||||
otelTraceId: otelTraceId ?? null,
|
||||
traceLine,
|
||||
|
||||
@@ -16,7 +16,7 @@ export { apiKeysAPI } from "./apiKeys";
|
||||
export { usageAPI } from "./usage";
|
||||
export { billingAPI } from "./billing";
|
||||
export { systemAPI, type SkillUploadFileInput } from "./system";
|
||||
export { projectManagementAPI, type MdtodoExecutionContext, type MdtodoFileRecord, type MdtodoLaunchContextResponse, type MdtodoReportPreviewRecord, type MdtodoTaskDetailRecord, type MdtodoTaskLinkRecord, type MdtodoTaskMutationInput, type MdtodoTaskMutationResponse, type MdtodoTaskPage, type MdtodoTaskRecord, type ProjectNavigationResponse, type ProjectRecord, type ProjectSource, type ProjectSourceInput, type ProjectWorkbenchLinkRecord } from "./projectManagement";
|
||||
export { projectManagementAPI, type MdtodoExecutionContext, type MdtodoFileRecord, type MdtodoLaunchContextResponse, type MdtodoReportPreviewRecord, type MdtodoTaskDetailAuthority, type MdtodoTaskDetailRecord, type MdtodoTaskLinkRecord, type MdtodoTaskMutationInput, type MdtodoTaskMutationResponse, type MdtodoTaskPage, type MdtodoTaskRecord, type ProjectNavigationResponse, type ProjectRecord, type ProjectSource, type ProjectSourceInput, type ProjectWorkbenchLinkRecord } from "./projectManagement";
|
||||
|
||||
import { fetchJson } from "./client";
|
||||
import { accessAPI } from "./access";
|
||||
|
||||
@@ -156,6 +156,16 @@ export interface MdtodoTaskDetailRecord {
|
||||
valuesRedacted?: boolean;
|
||||
}
|
||||
|
||||
export interface MdtodoTaskDetailAuthority {
|
||||
state: "ready" | "unavailable";
|
||||
authority: "source-markdown" | "project-management-projection";
|
||||
sourceKind?: string | null;
|
||||
code?: string | null;
|
||||
message?: string | null;
|
||||
retryable?: boolean;
|
||||
valuesRedacted?: boolean;
|
||||
}
|
||||
|
||||
export interface MdtodoExecutionContext {
|
||||
sourceId?: string | null;
|
||||
sourceKind?: string | null;
|
||||
@@ -216,7 +226,13 @@ export interface MdtodoTaskPage {
|
||||
hasMore?: boolean;
|
||||
}
|
||||
export interface MdtodoTaskListResponse extends ProjectManagementEnvelope { tasks?: MdtodoTaskRecord[]; page?: MdtodoTaskPage }
|
||||
export interface MdtodoTaskDetailResponse extends ProjectManagementEnvelope { task?: MdtodoTaskRecord; detail?: MdtodoTaskDetailRecord; file?: MdtodoFileRecord }
|
||||
export interface MdtodoTaskDetailResponse extends ProjectManagementEnvelope {
|
||||
status?: "ready" | "partial";
|
||||
task?: MdtodoTaskRecord;
|
||||
detail?: MdtodoTaskDetailRecord | null;
|
||||
file?: MdtodoFileRecord;
|
||||
detailAuthority?: MdtodoTaskDetailAuthority;
|
||||
}
|
||||
export interface MdtodoLaunchContextResponse extends ProjectManagementEnvelope { task?: MdtodoTaskRecord; source?: Partial<ProjectSource>; executionContext?: MdtodoExecutionContext; launchContext?: Record<string, unknown> }
|
||||
export interface MdtodoReportPreviewResponse extends ProjectManagementEnvelope { task?: MdtodoTaskRecord; link?: MdtodoTaskLinkRecord; report?: MdtodoReportPreviewRecord; file?: MdtodoFileRecord }
|
||||
export interface MdtodoTaskMutationInput {
|
||||
|
||||
@@ -9,6 +9,7 @@ defineProps<{
|
||||
newTitle: string;
|
||||
newBody: string;
|
||||
loading: boolean;
|
||||
disabled: boolean;
|
||||
error: string | null;
|
||||
hasSelectedTask: boolean;
|
||||
}>();
|
||||
@@ -24,15 +25,16 @@ const emit = defineEmits<{
|
||||
<template>
|
||||
<BaseDialog :open="show" title="新建任务" description="选择根任务、子任务或同级延续,并写入当前 MDTODO source。" :busy="loading" @close="emit('close')">
|
||||
<div class="task-create-form" data-testid="mdtodo-task-create-dialog">
|
||||
<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)" />
|
||||
<label><span>New task</span><input :value="newTitle" data-testid="mdtodo-new-title" :disabled="loading || disabled" placeholder="新任务标题" @input="emit('update:newTitle', ($event.target as HTMLInputElement).value)" /></label>
|
||||
<textarea :value="newBody" data-testid="mdtodo-new-body" :disabled="loading || disabled" rows="5" placeholder="新任务正文,可留空。" @input="emit('update:newBody', ($event.target as HTMLTextAreaElement).value)" />
|
||||
<p v-if="disabled" class="source-error" data-testid="mdtodo-task-create-disabled">当前任务仅有最近一次投影;Node 恢复并读到源 Markdown 后才能新建任务。</p>
|
||||
<p v-if="error" class="source-error" data-testid="mdtodo-task-mutation-error">{{ error }}</p>
|
||||
</div>
|
||||
<template #footer>
|
||||
<button class="btn btn-secondary" type="button" :disabled="loading" @click="emit('close')">取消</button>
|
||||
<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-primary" type="button" data-testid="mdtodo-continue-task" :disabled="loading || !hasSelectedTask" @click="emit('create', 'continue')">延续同级</button>
|
||||
<button class="btn btn-secondary" type="button" data-testid="mdtodo-add-root" :disabled="loading || disabled" @click="emit('create', 'root')">新增根任务</button>
|
||||
<button class="btn btn-secondary" type="button" data-testid="mdtodo-add-subtask" :disabled="loading || disabled || !hasSelectedTask" @click="emit('create', 'subtask')">新增子任务</button>
|
||||
<button class="btn btn-primary" type="button" data-testid="mdtodo-continue-task" :disabled="loading || disabled || !hasSelectedTask" @click="emit('create', 'continue')">延续同级</button>
|
||||
</template>
|
||||
</BaseDialog>
|
||||
</template>
|
||||
|
||||
@@ -101,6 +101,8 @@ const selectedFile = computed(() => taskData.files.value.find((f) => f.fileRef =
|
||||
const selectedTask = computed(() => taskData.tasks.value.find((t) => t.taskRef === selectedTaskRef.value) ?? null);
|
||||
const selectedTaskProjection = computed(() => selectedTask.value?.task ?? null);
|
||||
const fileScopedTasks = computed(() => selectedFileRef.value ? taskData.tasks.value.filter((t) => t.fileRef === selectedFileRef.value) : taskData.tasks.value);
|
||||
const taskDetailPartial = computed(() => taskData.taskDetailAuthority.value?.state === "unavailable");
|
||||
const taskMutationDisabled = computed(() => Boolean(selectedTask.value && (taskData.taskDetailLoading.value || !taskData.taskDetail.value || taskDetailPartial.value)));
|
||||
|
||||
const filteredTasks = computed(() => {
|
||||
const query = taskSearch.value.trim().toLowerCase();
|
||||
@@ -125,7 +127,9 @@ const selectedTaskLinks = computed(() => {
|
||||
if (detailReportLinks.length || detailLinks.length) return detailReportLinks;
|
||||
const projectedLinks = selectedTask.value?.links ?? selectedTaskProjection.value?.links ?? [];
|
||||
const projectedReportLinks = reportLinksOnly(projectedLinks);
|
||||
if (projectedReportLinks.length || projectedLinks.length) return projectedReportLinks;
|
||||
if (projectedReportLinks.length || projectedLinks.length) {
|
||||
return taskDetailPartial.value ? projectedReportLinks.map(projectedUnavailableReportLink) : projectedReportLinks;
|
||||
}
|
||||
const linkCount = Number(selectedTask.value?.linkCount ?? selectedTaskProjection.value?.linkCount ?? 0);
|
||||
if (selectedTask.value && linkCount > 0) return [projectedReportLink(selectedTask.value, linkCount)];
|
||||
return [];
|
||||
@@ -171,6 +175,15 @@ function projectedReportLink(task: MdtodoTaskRecord, linkCount: number): MdtodoT
|
||||
};
|
||||
}
|
||||
|
||||
function projectedUnavailableReportLink(link: MdtodoTaskLinkRecord): MdtodoTaskLinkRecord {
|
||||
return {
|
||||
...link,
|
||||
availability: "projected-link-unavailable",
|
||||
message: "任务投影保留了报告索引,但 HWPOD 源当前不可读;Node 恢复后可打开权威报告。",
|
||||
valuesRedacted: true
|
||||
};
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void refreshMdtodoProviderOptions();
|
||||
void loadPage();
|
||||
@@ -243,6 +256,14 @@ watch(taskData.taskDetail, (detail) => {
|
||||
if (detail?.taskRef === selectedTaskRef.value && !editingBody.value) editBody.value = detail.body || "";
|
||||
});
|
||||
|
||||
watch(taskMutationDisabled, (disabled) => {
|
||||
if (!disabled) return;
|
||||
editingTitle.value = false;
|
||||
editingBody.value = false;
|
||||
deleteConfirm.value = false;
|
||||
showTaskCreateDialog.value = false;
|
||||
});
|
||||
|
||||
function beginTaskWindowRefresh(mode: RefreshMode): number {
|
||||
const refreshSeq = ++taskWindowRefreshSeq;
|
||||
taskTreeRefreshMode.value = mode;
|
||||
@@ -437,6 +458,7 @@ async function reloadAfterMutation(): Promise<void> {
|
||||
}
|
||||
|
||||
async function saveTaskBasics(): Promise<void> {
|
||||
if (rejectUnavailableMutation()) return;
|
||||
const task = selectedTask.value;
|
||||
if (!task) return;
|
||||
const input: MdtodoTaskMutationInput = { expectedFingerprint: taskFingerprint() };
|
||||
@@ -455,6 +477,7 @@ async function saveTaskBasics(): Promise<void> {
|
||||
}
|
||||
|
||||
async function saveTaskBody(): Promise<void> {
|
||||
if (rejectUnavailableMutation()) return;
|
||||
const task = selectedTask.value;
|
||||
if (!task) return;
|
||||
const nextRef = await mutation.updateTask(task.taskRef, { expectedFingerprint: taskFingerprint(), body: editBody.value }, "正文已保存", reloadAfterMutation);
|
||||
@@ -465,6 +488,7 @@ async function saveTaskBody(): Promise<void> {
|
||||
}
|
||||
|
||||
async function createTask(kind: "root" | "subtask" | "continue"): Promise<void> {
|
||||
if (rejectUnavailableMutation()) return;
|
||||
const title = newTaskTitle.value.trim();
|
||||
if (!title) {
|
||||
mutation.taskMutationError.value = "新任务标题不能为空";
|
||||
@@ -491,6 +515,7 @@ async function createTask(kind: "root" | "subtask" | "continue"): Promise<void>
|
||||
}
|
||||
|
||||
async function deleteSelectedTask(): Promise<void> {
|
||||
if (rejectUnavailableMutation()) return;
|
||||
const task = selectedTask.value;
|
||||
if (!task) return;
|
||||
if (!deleteConfirm.value) {
|
||||
@@ -504,6 +529,18 @@ async function deleteSelectedTask(): Promise<void> {
|
||||
if (!mutation.taskMutationError.value) selectedTaskRef.value = taskData.tasks.value[0]?.taskRef ?? null;
|
||||
}
|
||||
|
||||
function rejectUnavailableMutation(): boolean {
|
||||
if (!taskMutationDisabled.value) return false;
|
||||
mutation.taskMutationMessage.value = null;
|
||||
mutation.taskMutationError.value = "当前仅有最近一次任务投影;Node 恢复并读到源 Markdown 后才能写入。";
|
||||
return true;
|
||||
}
|
||||
|
||||
function openTaskCreateDialog(): void {
|
||||
if (rejectUnavailableMutation()) return;
|
||||
showTaskCreateDialog.value = true;
|
||||
}
|
||||
|
||||
function openReport(link: typeof selectedTaskLinks.value[number]): void {
|
||||
const taskRef = selectedTaskRef.value;
|
||||
if (!taskRef) return;
|
||||
@@ -588,12 +625,13 @@ function setError(err: unknown): void {
|
||||
:selected-file-ref="selectedFileRef"
|
||||
:file-loading="taskData.taskLoading.value && taskTreeRefreshMode === 'blocking'"
|
||||
:reindex-loading="source.sourceReindexLoading.value"
|
||||
:create-disabled="taskMutationDisabled"
|
||||
@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"
|
||||
@open-create-task="openTaskCreateDialog"
|
||||
/>
|
||||
|
||||
<div v-if="source.sourceMessage.value || source.sourceError.value" class="mdtodo-notices">
|
||||
@@ -642,13 +680,14 @@ function setError(err: unknown): void {
|
||||
:refresh-mode="taskTreeRefreshMode"
|
||||
:search="taskSearch"
|
||||
:status-filter="taskStatusFilter"
|
||||
:create-disabled="taskMutationDisabled"
|
||||
@update:search="taskSearch = $event"
|
||||
@update:status-filter="taskStatusFilter = $event"
|
||||
@update:collapsed="taskPaneCollapsed = $event"
|
||||
@update:collapsed-task-refs="collapsedTaskRefs = $event"
|
||||
@select-task="selectTask"
|
||||
@load-more="loadMoreTasks"
|
||||
@open-create-task="showTaskCreateDialog = true"
|
||||
@open-create-task="openTaskCreateDialog"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -657,6 +696,8 @@ function setError(err: unknown): void {
|
||||
:detail="taskData.taskDetail.value"
|
||||
:detail-loading="taskData.taskDetailLoading.value"
|
||||
:detail-error="taskData.taskDetailError.value"
|
||||
:detail-partial="taskDetailPartial"
|
||||
:mutation-disabled="taskMutationDisabled"
|
||||
:body-text="selectedTaskBody"
|
||||
:body-html="selectedTaskBodyHtml"
|
||||
:links="selectedTaskLinks"
|
||||
@@ -737,6 +778,7 @@ function setError(err: unknown): void {
|
||||
:new-title="newTaskTitle"
|
||||
:new-body="newTaskBody"
|
||||
:loading="mutation.taskMutationLoading.value"
|
||||
:disabled="taskMutationDisabled"
|
||||
:error="mutation.taskMutationError.value"
|
||||
:has-selected-task="Boolean(selectedTask)"
|
||||
@close="showTaskCreateDialog = false"
|
||||
|
||||
@@ -21,6 +21,8 @@ const props = defineProps<{
|
||||
detail: MdtodoTaskDetailRecord | null;
|
||||
detailLoading: boolean;
|
||||
detailError: string | null;
|
||||
detailPartial: boolean;
|
||||
mutationDisabled: boolean;
|
||||
bodyText: string;
|
||||
bodyHtml: string;
|
||||
links: MdtodoTaskLinkRecord[];
|
||||
@@ -80,21 +82,21 @@ function normalizeTaskText(value?: string | null): string {
|
||||
<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>
|
||||
<input :value="editTitle" data-testid="mdtodo-edit-title" :disabled="mutationLoading || mutationDisabled" @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 || mutationDisabled" @click="emit('saveTaskBasics')">保存</button>
|
||||
<button class="btn btn-secondary" type="button" :disabled="mutationLoading || mutationDisabled" @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>
|
||||
<button v-else-if="task" class="task-title-read" type="button" data-testid="mdtodo-title-read" :disabled="mutationDisabled" :title="mutationDisabled ? '当前为只读投影' : '双击编辑标题'" @dblclick="!mutationDisabled && 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)">
|
||||
<select :value="editStatus" data-testid="mdtodo-edit-status" :disabled="mutationLoading || mutationDisabled" 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-secondary" type="button" data-testid="mdtodo-status-save" :disabled="mutationLoading || mutationDisabled" @click="emit('saveTaskBasics')">保存状态</button>
|
||||
<label class="provider-field" for="mdtodo-provider-profile">
|
||||
<span>模型通道</span>
|
||||
<select id="mdtodo-provider-profile" :value="providerProfile" data-testid="mdtodo-provider-profile" :disabled="launchLoading" aria-label="模型通道" @change="emit('update:providerProfile', ($event.target as HTMLSelectElement).value)">
|
||||
@@ -118,17 +120,17 @@ function normalizeTaskText(value?: string | null): string {
|
||||
</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>
|
||||
<p v-if="detailError" :class="detailPartial ? 'source-message task-detail-notice' : 'source-error task-detail-error'" :data-testid="detailPartial ? 'mdtodo-task-detail-notice' : 'mdtodo-task-detail-error'">{{ detailError }}</p>
|
||||
|
||||
<section class="task-body-section" data-testid="mdtodo-task-body">
|
||||
<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)" />
|
||||
<textarea :value="editBody" data-testid="mdtodo-edit-body" :disabled="mutationLoading || mutationDisabled" 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>
|
||||
<button class="btn btn-secondary" type="button" data-testid="mdtodo-edit-body-save" :disabled="mutationLoading || mutationDisabled" @click="emit('saveTaskBody')">保存正文</button>
|
||||
<button class="btn btn-secondary" type="button" :disabled="mutationLoading || mutationDisabled" @click="emit('cancelBodyEdit')">取消</button>
|
||||
</div>
|
||||
</div>
|
||||
<article v-else class="markdown-body task-body-rendered" data-testid="mdtodo-body-rendered" @dblclick="emit('beginBodyEdit')">
|
||||
<article v-else class="markdown-body task-body-rendered" data-testid="mdtodo-body-rendered" :aria-disabled="mutationDisabled ? 'true' : 'false'" :title="mutationDisabled ? '当前为只读投影' : '双击编辑正文'" @dblclick="!mutationDisabled && emit('beginBodyEdit')">
|
||||
<div v-if="bodyHtml && bodyDiffersFromTitle" v-html="bodyHtml" />
|
||||
<p v-else-if="!task" class="empty-inline">暂无补充正文</p>
|
||||
<p v-else class="empty-inline">{{ task.title || task.taskId || '暂无正文' }}</p>
|
||||
@@ -149,8 +151,8 @@ function normalizeTaskText(value?: string | null): string {
|
||||
</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>
|
||||
<button class="btn btn-secondary" type="button" data-testid="mdtodo-delete-task" :disabled="mutationLoading || mutationDisabled" @click="emit('deleteTask')">{{ deleteConfirm ? '确认删除' : '删除任务' }}</button>
|
||||
<button v-if="deleteConfirm" class="btn btn-secondary" type="button" data-testid="mdtodo-delete-cancel" :disabled="mutationLoading || mutationDisabled" @click="emit('cancelDelete')">取消</button>
|
||||
</div>
|
||||
</footer>
|
||||
</template>
|
||||
|
||||
@@ -19,6 +19,7 @@ const props = withDefaults(defineProps<{
|
||||
refreshMode?: "persistent" | "blocking";
|
||||
search: string;
|
||||
statusFilter: string;
|
||||
createDisabled: boolean;
|
||||
}>(), {
|
||||
refreshMode: "blocking"
|
||||
});
|
||||
@@ -41,6 +42,15 @@ const tree = useRxxTaskTree(
|
||||
const visibleTaskRows = computed(() => tree.visibleTaskRows.value);
|
||||
const hasVisibleTasks = computed(() => visibleTaskRows.value.length > 0);
|
||||
const showHeaderRefresh = computed(() => props.loading && props.refreshMode === "persistent" && hasVisibleTasks.value);
|
||||
const isFiltered = computed(() => props.tasks.length !== props.fileScopedTaskCount || Boolean(props.search.trim()) || props.statusFilter !== "all");
|
||||
const statusSummary = computed(() => {
|
||||
const counts = new Map<string, number>();
|
||||
for (const task of props.tasks) {
|
||||
const status = task.status || "unknown";
|
||||
counts.set(status, (counts.get(status) ?? 0) + 1);
|
||||
}
|
||||
return [...counts.entries()].map(([status, count]) => ({ status, count, label: statusLabel(status) }));
|
||||
});
|
||||
|
||||
function toggleTask(task: MdtodoTaskRecord): void {
|
||||
const next = new Set(props.collapsedTaskRefs);
|
||||
@@ -62,7 +72,7 @@ function toggleTask(task: MdtodoTaskRecord): void {
|
||||
<span class="task-refresh-dot" aria-hidden="true" />
|
||||
<span v-if="!collapsed">同步中</span>
|
||||
</span>
|
||||
<button class="icon-button" type="button" data-testid="mdtodo-tree-new-task-open" aria-label="新建任务" @click="emit('openCreateTask')">+</button>
|
||||
<button class="icon-button" type="button" data-testid="mdtodo-tree-new-task-open" aria-label="新建任务" :disabled="createDisabled" :title="createDisabled ? '当前任务正文不可读,Node 恢复后才能写入' : undefined" @click="emit('openCreateTask')">+</button>
|
||||
</div>
|
||||
</header>
|
||||
<template v-if="!collapsed">
|
||||
@@ -92,6 +102,12 @@ function toggleTask(task: MdtodoTaskRecord): void {
|
||||
<button v-if="taskPage?.hasMore" class="task-load-more" type="button" data-testid="mdtodo-task-load-more" :disabled="loading" @click="emit('loadMore')">
|
||||
{{ loading ? '加载中…' : `加载更多(已载入 ${fileScopedTaskCount} / ${taskPage.total ?? '?'})` }}
|
||||
</button>
|
||||
<footer class="task-tree-facts" data-testid="mdtodo-task-tree-facts">
|
||||
<span v-if="isFiltered"><strong>{{ props.tasks.length }}</strong> 当前筛选</span>
|
||||
<span v-for="item in statusSummary" :key="item.status"><strong>{{ item.count }}</strong> {{ item.label }}</span>
|
||||
<span><strong>{{ fileScopedTaskCount }}</strong> 已载入</span>
|
||||
<span v-if="taskPage?.total !== undefined"><strong>{{ taskPage.total }}</strong> 总计</span>
|
||||
</footer>
|
||||
</div>
|
||||
</RefreshBoundary>
|
||||
</template>
|
||||
@@ -99,7 +115,7 @@ function toggleTask(task: MdtodoTaskRecord): void {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mdtodo-task-panel { display: grid; min-width: 0; min-height: 0; height: 100%; max-height: none; align-content: start; grid-template-rows: auto auto minmax(0, 1fr); gap: 8px; overflow: hidden; padding: 10px 8px 10px 10px; }
|
||||
.mdtodo-task-panel { display: grid; min-width: 0; min-height: 0; height: 100%; max-height: none; align-content: stretch; grid-template-rows: auto auto minmax(0, 1fr); gap: 8px; overflow: hidden; padding: 10px 8px 10px 10px; }
|
||||
.mdtodo-task-panel[data-collapsed="true"] { justify-items: center; padding: 8px 4px; overflow: hidden; grid-template-rows: auto; }
|
||||
.mdtodo-panel-header { display: grid; min-width: 0; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; align-items: center; }
|
||||
.task-tree-title { min-width: 0; }
|
||||
@@ -111,7 +127,7 @@ function toggleTask(task: MdtodoTaskRecord): void {
|
||||
.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-width: 0; min-height: 0; height: 100%; }
|
||||
.task-tree { display: grid; min-width: 0; min-height: 0; height: 100%; align-content: start; gap: 1px; overflow: auto; border-top: 1px solid #e3e9ee; padding-top: 2px; }
|
||||
.task-tree { display: flex; min-width: 0; min-height: 0; height: 100%; flex-direction: column; 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: minmax(0, 1fr) 24px; 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; }
|
||||
@@ -119,6 +135,9 @@ function toggleTask(task: MdtodoTaskRecord): void {
|
||||
.task-toggle:disabled { border-color: transparent; background: transparent; }
|
||||
.task-load-more { min-height: 34px; margin: 7px 4px; border: 1px solid #8abcb7; border-radius: 6px; background: #effaf8; color: #126f6a; font-size: 11px; font-weight: 800; }
|
||||
.task-load-more:disabled { opacity: .55; }
|
||||
.task-tree-facts { display: flex; min-height: 34px; flex-wrap: wrap; align-items: center; gap: 5px 10px; margin-top: auto; border-top: 1px solid #d9e2e8; background: #f1f5f7; color: #5d6a73; padding: 7px 8px; font-size: 10px; }
|
||||
.task-tree-facts span { white-space: nowrap; }
|
||||
.task-tree-facts strong { color: #16353a; font-size: 11px; }
|
||||
.task-row { display: grid; width: 100%; min-width: 0; grid-template-columns: auto minmax(0, 1fr) auto; gap: 7px; align-items: center; border: 1px solid transparent; border-radius: 5px; background: transparent; color: #111827; padding: 7px 8px; text-align: left; }
|
||||
.task-row:hover { border-color: #d9e2e8; background: #fff; }
|
||||
.task-row[data-selected="true"] { border-color: #5bb7af; background: #e8faf6; box-shadow: inset 3px 0 0 #0f766e; }
|
||||
|
||||
@@ -12,6 +12,7 @@ defineProps<{
|
||||
selectedFileRef: string | null;
|
||||
fileLoading: boolean;
|
||||
reindexLoading: boolean;
|
||||
createDisabled: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -46,7 +47,7 @@ function onFileSelect(event: Event): void {
|
||||
</select>
|
||||
</label>
|
||||
<div class="toolbar-actions">
|
||||
<button class="btn btn-secondary toolbar-create" type="button" data-testid="mdtodo-new-task-open" @click="emit('openCreateTask')">+ 任务</button>
|
||||
<button class="btn btn-secondary toolbar-create" type="button" data-testid="mdtodo-new-task-open" :disabled="createDisabled" :title="createDisabled ? '当前任务正文不可读,Node 恢复后才能写入' : undefined" @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>
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
// SPEC: PJ2026-01040409 MDTODO前端重写; PJ2026-010405 云端控制台.
|
||||
// Responsibility: 验证 HWPOD 源离线时任务详情以 typed partial 投影继续呈现。
|
||||
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
const { taskDetail } = vi.hoisted(() => ({ taskDetail: vi.fn() }));
|
||||
|
||||
vi.mock("@/api", () => ({
|
||||
projectManagementAPI: { taskDetail }
|
||||
}));
|
||||
|
||||
import { useMdtodoTaskData } from "./useMdtodoTaskData";
|
||||
|
||||
describe("useMdtodoTaskData", () => {
|
||||
beforeEach(() => taskDetail.mockReset());
|
||||
|
||||
test("keeps the projected task surface usable when live HWPOD detail is unavailable", async () => {
|
||||
taskDetail.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
data: {
|
||||
status: "partial",
|
||||
detail: null,
|
||||
detailAuthority: {
|
||||
state: "unavailable",
|
||||
authority: "project-management-projection",
|
||||
sourceKind: "hwpod-workspace",
|
||||
code: "hwpod_node_offline",
|
||||
message: "继续展示最近一次项目投影。",
|
||||
retryable: true,
|
||||
valuesRedacted: true
|
||||
}
|
||||
}
|
||||
});
|
||||
const state = useMdtodoTaskData();
|
||||
|
||||
await state.loadTaskDetail("mdtodo:source:file:R1");
|
||||
|
||||
expect(state.taskDetail.value).toBeNull();
|
||||
expect(state.taskDetailAuthority.value).toMatchObject({ state: "unavailable", code: "hwpod_node_offline" });
|
||||
expect(state.taskDetailError.value).toBe("继续展示最近一次项目投影。");
|
||||
expect(state.taskDetailLoading.value).toBe(false);
|
||||
});
|
||||
|
||||
test("clears the projection notice when source Markdown becomes readable again", async () => {
|
||||
taskDetail
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
data: { detail: null, detailAuthority: { state: "unavailable", authority: "project-management-projection", message: "离线" } }
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
data: {
|
||||
status: "ready",
|
||||
detail: { taskRef: "mdtodo:source:file:R1", taskId: "R1", body: "实时正文" },
|
||||
detailAuthority: { state: "ready", authority: "source-markdown" }
|
||||
}
|
||||
});
|
||||
const state = useMdtodoTaskData();
|
||||
|
||||
await state.loadTaskDetail("mdtodo:source:file:R1");
|
||||
await state.loadTaskDetail("mdtodo:source:file:R1");
|
||||
|
||||
expect(state.taskDetail.value?.body).toBe("实时正文");
|
||||
expect(state.taskDetailAuthority.value?.state).toBe("ready");
|
||||
expect(state.taskDetailError.value).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,7 @@
|
||||
// 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";
|
||||
import { projectManagementAPI, type MdtodoFileRecord, type MdtodoTaskRecord, type MdtodoTaskDetailAuthority, type MdtodoTaskDetailRecord, type MdtodoTaskPage, type ProjectWorkbenchLinkRecord } from "@/api";
|
||||
|
||||
const TASK_WINDOW_LIMIT = 100;
|
||||
|
||||
@@ -22,6 +22,7 @@ export function useMdtodoTaskData() {
|
||||
const tasks = ref<MdtodoTaskRecord[]>([]);
|
||||
const taskPage = ref<MdtodoTaskPage | null>(null);
|
||||
const taskDetail = ref<MdtodoTaskDetailRecord | null>(null);
|
||||
const taskDetailAuthority = ref<MdtodoTaskDetailAuthority | null>(null);
|
||||
const links = ref<ProjectWorkbenchLinkRecord[]>([]);
|
||||
const taskLoading = ref(false);
|
||||
const taskDetailLoading = ref(false);
|
||||
@@ -104,6 +105,7 @@ export function useMdtodoTaskData() {
|
||||
async function loadTaskDetail(taskRef: string | null): Promise<void> {
|
||||
const requestSeq = ++taskDetailRequestSeq;
|
||||
taskDetailError.value = null;
|
||||
taskDetailAuthority.value = null;
|
||||
if (!taskRef) {
|
||||
taskDetail.value = null;
|
||||
taskDetailLoading.value = false;
|
||||
@@ -115,9 +117,14 @@ export function useMdtodoTaskData() {
|
||||
if (!response.ok) throw response;
|
||||
if (requestSeq !== taskDetailRequestSeq) return;
|
||||
taskDetail.value = response.data?.detail ?? null;
|
||||
taskDetailAuthority.value = response.data?.detailAuthority ?? null;
|
||||
if (taskDetailAuthority.value?.state === "unavailable") {
|
||||
taskDetailError.value = taskDetailAuthority.value.message || "任务详情 authority 当前不可用;继续展示最近一次投影。";
|
||||
}
|
||||
} catch (err) {
|
||||
if (requestSeq !== taskDetailRequestSeq) return;
|
||||
taskDetail.value = null;
|
||||
taskDetailAuthority.value = null;
|
||||
taskDetailError.value = err && typeof err === "object" && "error" in err ? String((err as { error?: string }).error || "任务详情加载失败") : "任务详情加载失败";
|
||||
} finally {
|
||||
if (requestSeq === taskDetailRequestSeq) taskDetailLoading.value = false;
|
||||
@@ -142,6 +149,7 @@ export function useMdtodoTaskData() {
|
||||
|
||||
function clearTaskDetailAndReport(): void {
|
||||
taskDetail.value = null;
|
||||
taskDetailAuthority.value = null;
|
||||
taskDetailError.value = null;
|
||||
}
|
||||
|
||||
@@ -150,6 +158,7 @@ export function useMdtodoTaskData() {
|
||||
tasks,
|
||||
taskPage,
|
||||
taskDetail,
|
||||
taskDetailAuthority,
|
||||
links,
|
||||
taskLoading,
|
||||
taskDetailLoading,
|
||||
|
||||
@@ -42,6 +42,13 @@ test.describe("project management pages", () => {
|
||||
expect(outlineRatio).toBeGreaterThan(0.24);
|
||||
expect(outlineRatio).toBeLessThan(0.36);
|
||||
await expect(page.getByTestId("mdtodo-task-tree")).toContainText("实现项目根导航");
|
||||
await expect(page.getByTestId("mdtodo-task-tree-facts")).toContainText("已载入");
|
||||
const taskTreeBottomGap = await page.getByTestId("mdtodo-task-tree").evaluate((panel) => {
|
||||
const facts = panel.querySelector('[data-testid="mdtodo-task-tree-facts"]');
|
||||
if (!facts) return Number.POSITIVE_INFINITY;
|
||||
return Math.max(0, panel.getBoundingClientRect().bottom - facts.getBoundingClientRect().bottom);
|
||||
});
|
||||
expect(taskTreeBottomGap).toBeLessThanOrEqual(12);
|
||||
await expect(page.locator('[data-task-id="R1"]')).toBeVisible();
|
||||
await expect(page.locator('[data-task-id="R1.1"]')).toBeVisible();
|
||||
await expect(page.locator('[data-task-id="R1.1.1"]')).toBeVisible();
|
||||
@@ -195,4 +202,51 @@ test.describe("project management pages", () => {
|
||||
await expect(page.locator('[data-task-id="R204"]')).toBeVisible();
|
||||
await expect(page).toHaveURL((url) => url.pathname.endsWith("/files/file_long/tasks/R205") && url.searchParams.get("provider") === "codex-api");
|
||||
});
|
||||
|
||||
test("keeps an offline HWPOD projection read-only without requesting live reports", async ({ page }) => {
|
||||
let reportPreviewRequests = 0;
|
||||
await page.route("**/v1/project-management/mdtodo/task-detail?**", async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
json: {
|
||||
ok: true,
|
||||
contractVersion: "project-management-v1",
|
||||
serviceId: "hwlab-project-management",
|
||||
status: "partial",
|
||||
detail: null,
|
||||
detailAuthority: {
|
||||
state: "unavailable",
|
||||
authority: "project-management-projection",
|
||||
sourceKind: "hwpod-workspace",
|
||||
code: "hwpod_node_offline",
|
||||
message: "HWPOD 源当前不可读;页面继续展示最近一次项目投影,正文编辑和报告预览需等待 Node 恢复。",
|
||||
retryable: true,
|
||||
valuesRedacted: true
|
||||
},
|
||||
valuesRedacted: true
|
||||
}
|
||||
});
|
||||
});
|
||||
await page.route("**/v1/project-management/mdtodo/report-preview?**", async (route) => {
|
||||
reportPreviewRequests += 1;
|
||||
await route.fulfill({ status: 503, contentType: "application/json", json: { ok: false, code: "hwpod_node_offline", message: "must not be requested" } });
|
||||
});
|
||||
|
||||
await page.goto("/projects/mdtodo/sources/hwlab-v03-mdtodo/files/file_plan/tasks/R1");
|
||||
await expect(page.getByTestId("mdtodo-task-detail-notice")).toContainText("最近一次项目投影");
|
||||
await expect(page.getByTestId("mdtodo-title-read")).toBeDisabled();
|
||||
await expect(page.getByTestId("mdtodo-edit-status")).toBeDisabled();
|
||||
await expect(page.getByTestId("mdtodo-status-save")).toBeDisabled();
|
||||
await expect(page.getByTestId("mdtodo-delete-task")).toBeDisabled();
|
||||
await expect(page.getByTestId("mdtodo-new-task-open")).toBeDisabled();
|
||||
await expect(page.getByTestId("mdtodo-tree-new-task-open")).toBeDisabled();
|
||||
await page.getByTestId("mdtodo-body-rendered").dblclick({ force: true });
|
||||
await expect(page.getByTestId("mdtodo-edit-body")).toHaveCount(0);
|
||||
|
||||
await page.getByTestId("mdtodo-report-link").click();
|
||||
await expect(page.getByTestId("mdtodo-report-preview")).toContainText("报告索引待刷新");
|
||||
expect(reportPreviewRequests).toBe(0);
|
||||
await expect(page.getByTestId("mdtodo-workbench-launch")).toBeEnabled();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user