Merge pull request #2205 from pikasTech/fix/2183-mdtodo-task-window
fix: 收敛 MDTODO 大 source 任务窗口
This commit is contained in:
@@ -807,6 +807,8 @@ lanes:
|
|||||||
HWLAB_PROJECT_MANAGEMENT_SOURCE_ID: hwlab-v03-mdtodo
|
HWLAB_PROJECT_MANAGEMENT_SOURCE_ID: hwlab-v03-mdtodo
|
||||||
HWLAB_PROJECT_MANAGEMENT_SOURCE_NAME: HWLAB v0.3 MDTODO
|
HWLAB_PROJECT_MANAGEMENT_SOURCE_NAME: HWLAB v0.3 MDTODO
|
||||||
HWLAB_PROJECT_MANAGEMENT_PROJECT_ID: project_hwlab_v03
|
HWLAB_PROJECT_MANAGEMENT_PROJECT_ID: project_hwlab_v03
|
||||||
|
HWLAB_PROJECT_MANAGEMENT_MDTODO_TASK_DEFAULT_LIMIT: "200"
|
||||||
|
HWLAB_PROJECT_MANAGEMENT_MDTODO_TASK_MAX_LIMIT: "500"
|
||||||
HWLAB_PROJECT_MANAGEMENT_HWPOD_NODE_OPS_URL: http://hwlab-cloud-api.hwlab-v03.svc.cluster.local:6667/v1/hwpod-node-ops
|
HWLAB_PROJECT_MANAGEMENT_HWPOD_NODE_OPS_URL: http://hwlab-cloud-api.hwlab-v03.svc.cluster.local:6667/v1/hwpod-node-ops
|
||||||
HWLAB_PROJECT_MANAGEMENT_HWPOD_NODE_OPS_API_KEY: secretRef:hwlab-v03-master-server-admin-api-key/api-key
|
HWLAB_PROJECT_MANAGEMENT_HWPOD_NODE_OPS_API_KEY: secretRef:hwlab-v03-master-server-admin-api-key/api-key
|
||||||
HWLAB_PROJECT_MANAGEMENT_BOOTSTRAP_SOURCES_PATH: /etc/hwlab/project-management/sources.json
|
HWLAB_PROJECT_MANAGEMENT_BOOTSTRAP_SOURCES_PATH: /etc/hwlab/project-management/sources.json
|
||||||
|
|||||||
@@ -36,6 +36,11 @@ test("project management app exposes MDTODO read model and Workbench link writes
|
|||||||
expect(tasks.tasks.map((task) => task.taskId)).toEqual(["R1", "R2"]);
|
expect(tasks.tasks.map((task) => task.taskId)).toEqual(["R1", "R2"]);
|
||||||
expect(tasks.tasks.map((task) => task.status)).toEqual(["open", "done"]);
|
expect(tasks.tasks.map((task) => task.status)).toEqual(["open", "done"]);
|
||||||
expect(tasks.tasks.every((task) => task.projectId === "project_test")).toBe(true);
|
expect(tasks.tasks.every((task) => task.projectId === "project_test")).toBe(true);
|
||||||
|
expect(tasks.page).toMatchObject({ offset: 0, limit: 200, total: 2, hasMore: false });
|
||||||
|
|
||||||
|
const taskWindow = await json(app, "/v1/project-management/mdtodo/tasks?sourceId=test-mdtodo&limit=1&offset=1");
|
||||||
|
expect(taskWindow.tasks.map((task) => task.taskId)).toEqual(["R2"]);
|
||||||
|
expect(taskWindow.page).toMatchObject({ offset: 1, limit: 1, total: 2, hasMore: false });
|
||||||
|
|
||||||
const links = await json(app, "/v1/project-management/workbench-links?projectId=project_test");
|
const links = await json(app, "/v1/project-management/workbench-links?projectId=project_test");
|
||||||
expect(links.links).toEqual([]);
|
expect(links.links).toEqual([]);
|
||||||
@@ -64,6 +69,39 @@ test("project management app exposes MDTODO read model and Workbench link writes
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("project management task list defaults to a bounded window", async () => {
|
||||||
|
const root = await mkdtemp(join(tmpdir(), "hwlab-project-management-task-window-"));
|
||||||
|
try {
|
||||||
|
const body = ["# Demo", "", ...Array.from({ length: 5 }, (_, index) => `## R${index + 1} Task ${index + 1}`)].join("\n");
|
||||||
|
await writeFile(join(root, "MDTODO.md"), `${body}\n`, "utf8");
|
||||||
|
const app = createProjectManagementApp({
|
||||||
|
env: {
|
||||||
|
HWLAB_PROJECT_MANAGEMENT_MDTODO_TASK_DEFAULT_LIMIT: "2",
|
||||||
|
HWLAB_PROJECT_MANAGEMENT_MDTODO_TASK_MAX_LIMIT: "3"
|
||||||
|
},
|
||||||
|
store: createProjectManagementStore({ kind: "memory" }),
|
||||||
|
sourceRoot: root,
|
||||||
|
sourceId: "bounded-mdtodo",
|
||||||
|
projectId: "project_test"
|
||||||
|
});
|
||||||
|
|
||||||
|
const ready = await app.fetch(new Request("http://service/health/ready"));
|
||||||
|
expect(ready.status).toBe(200);
|
||||||
|
|
||||||
|
const defaultWindow = await json(app, "/v1/project-management/mdtodo/tasks?sourceId=bounded-mdtodo");
|
||||||
|
expect(defaultWindow.tasks.map((task) => task.taskId)).toEqual(["R1", "R2"]);
|
||||||
|
expect(defaultWindow.page).toMatchObject({ offset: 0, limit: 2, maxLimit: 3, total: 5, hasMore: true });
|
||||||
|
|
||||||
|
const cappedWindow = await json(app, "/v1/project-management/mdtodo/tasks?sourceId=bounded-mdtodo&limit=99");
|
||||||
|
expect(cappedWindow.tasks.map((task) => task.taskId)).toEqual(["R1", "R2", "R3"]);
|
||||||
|
expect(cappedWindow.page).toMatchObject({ offset: 0, limit: 3, maxLimit: 3, total: 5, hasMore: true });
|
||||||
|
|
||||||
|
await app.close();
|
||||||
|
} finally {
|
||||||
|
await rm(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("project management app configures HWPOD source and writes MDTODO content", async () => {
|
test("project management app configures HWPOD source and writes MDTODO content", async () => {
|
||||||
const root = await mkdtemp(join(tmpdir(), "hwlab-project-management-hwpod-"));
|
const root = await mkdtemp(join(tmpdir(), "hwlab-project-management-hwpod-"));
|
||||||
const defaultRoot = join(root, "default");
|
const defaultRoot = join(root, "default");
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ export function createProjectManagementApp(options = {}) {
|
|||||||
const projectId = safeToken(options.projectId ?? env.HWLAB_PROJECT_MANAGEMENT_PROJECT_ID, "project_hwlab_v03");
|
const projectId = safeToken(options.projectId ?? env.HWLAB_PROJECT_MANAGEMENT_PROJECT_ID, "project_hwlab_v03");
|
||||||
const displayName = String(options.displayName ?? env.HWLAB_PROJECT_MANAGEMENT_SOURCE_NAME ?? "HWLAB MDTODO").trim() || "HWLAB MDTODO";
|
const displayName = String(options.displayName ?? env.HWLAB_PROJECT_MANAGEMENT_SOURCE_NAME ?? "HWLAB MDTODO").trim() || "HWLAB MDTODO";
|
||||||
const maxFiles = positiveInteger(options.maxFiles ?? env.HWLAB_PROJECT_MANAGEMENT_MDTODO_MAX_FILES, 300);
|
const maxFiles = positiveInteger(options.maxFiles ?? env.HWLAB_PROJECT_MANAGEMENT_MDTODO_MAX_FILES, 300);
|
||||||
|
const taskMaxLimit = positiveInteger(options.taskMaxLimit ?? env.HWLAB_PROJECT_MANAGEMENT_MDTODO_TASK_MAX_LIMIT, 500);
|
||||||
|
const taskDefaultLimit = Math.min(taskMaxLimit, positiveInteger(options.taskDefaultLimit ?? env.HWLAB_PROJECT_MANAGEMENT_MDTODO_TASK_DEFAULT_LIMIT, 200));
|
||||||
const sourceAdapter = options.sourceAdapter ?? createMdtodoSourceAdapter({ env, hwpodNodeOpsHandler: options.hwpodNodeOpsHandler, fetchImpl: options.fetchImpl });
|
const sourceAdapter = options.sourceAdapter ?? createMdtodoSourceAdapter({ env, hwpodNodeOpsHandler: options.hwpodNodeOpsHandler, fetchImpl: options.fetchImpl });
|
||||||
const defaultSource = normalizeProjectSourceInput({
|
const defaultSource = normalizeProjectSourceInput({
|
||||||
sourceId,
|
sourceId,
|
||||||
@@ -146,12 +148,22 @@ export function createProjectManagementApp(options = {}) {
|
|||||||
return await handleReadMdtodoFile(url, store, sourceAdapter, fileContent.fileRef);
|
return await handleReadMdtodoFile(url, store, sourceAdapter, fileContent.fileRef);
|
||||||
}
|
}
|
||||||
if (url.pathname === "/v1/project-management/mdtodo/tasks") {
|
if (url.pathname === "/v1/project-management/mdtodo/tasks") {
|
||||||
|
const taskFilters = {
|
||||||
|
sourceId: queryToken(url, "sourceId"),
|
||||||
|
fileRef: queryToken(url, "fileRef"),
|
||||||
|
projectId: queryToken(url, "projectId"),
|
||||||
|
parentTaskRef: queryOpaque(url, "parentTaskRef"),
|
||||||
|
status: queryToken(url, "status"),
|
||||||
|
search: querySearch(url, "search")
|
||||||
|
};
|
||||||
|
const page = taskListPage(url, { defaultLimit: taskDefaultLimit, maxLimit: taskMaxLimit });
|
||||||
|
const [total, tasks] = await Promise.all([
|
||||||
|
store.countTasks(taskFilters),
|
||||||
|
store.listTasks({ ...taskFilters, limit: page.limit, offset: page.offset })
|
||||||
|
]);
|
||||||
return json(200, envelope({
|
return json(200, envelope({
|
||||||
tasks: await store.listTasks({
|
tasks,
|
||||||
sourceId: queryToken(url, "sourceId"),
|
page: { ...page, total, hasMore: page.offset + tasks.length < total }
|
||||||
fileRef: queryToken(url, "fileRef"),
|
|
||||||
projectId: queryToken(url, "projectId")
|
|
||||||
})
|
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
if (url.pathname === "/v1/project-management/workbench-links") {
|
if (url.pathname === "/v1/project-management/workbench-links") {
|
||||||
@@ -458,7 +470,8 @@ async function mutateTaskDocument({ store, sourceAdapter, sourceId, fileRef, exp
|
|||||||
async function getTaskByRef(store, taskRef) {
|
async function getTaskByRef(store, taskRef) {
|
||||||
const safeTaskRef = safeOpaqueValue(taskRef);
|
const safeTaskRef = safeOpaqueValue(taskRef);
|
||||||
if (!safeTaskRef) return null;
|
if (!safeTaskRef) return null;
|
||||||
return (await store.listTasks({})).find((task) => task.taskRef === safeTaskRef) ?? null;
|
if (typeof store.getTask === "function") return store.getTask(safeTaskRef);
|
||||||
|
return (await store.listTasks({ taskRef: safeTaskRef, limit: 1 }))[0] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function fileSummary(file) {
|
function fileSummary(file) {
|
||||||
@@ -608,6 +621,13 @@ function queryOpaque(url, name) {
|
|||||||
return /^[A-Za-z0-9_.:/#-]{1,320}$/u.test(String(value ?? "")) ? value : null;
|
return /^[A-Za-z0-9_.:/#-]{1,320}$/u.test(String(value ?? "")) ? value : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function querySearch(url, name) {
|
||||||
|
const value = url.searchParams.get(name);
|
||||||
|
if (value === null) return null;
|
||||||
|
const text = shortText(value, 120);
|
||||||
|
return text && /^[\p{L}\p{N}\s_.:/#-]{1,120}$/u.test(text) ? text : null;
|
||||||
|
}
|
||||||
|
|
||||||
function safeToken(value, fallback) {
|
function safeToken(value, fallback) {
|
||||||
const text = String(value ?? "").trim();
|
const text = String(value ?? "").trim();
|
||||||
return /^[A-Za-z0-9_.:-]{1,96}$/u.test(text) ? text : fallback;
|
return /^[A-Za-z0-9_.:-]{1,96}$/u.test(text) ? text : fallback;
|
||||||
@@ -617,3 +637,17 @@ function positiveInteger(value, fallback) {
|
|||||||
const number = Number(value);
|
const number = Number(value);
|
||||||
return Number.isInteger(number) && number > 0 ? number : fallback;
|
return Number.isInteger(number) && number > 0 ? number : fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function nonNegativeInteger(value, fallback) {
|
||||||
|
const number = Number(value);
|
||||||
|
return Number.isInteger(number) && number >= 0 ? number : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function taskListPage(url, options) {
|
||||||
|
const limit = Math.min(options.maxLimit, positiveInteger(url.searchParams.get("limit"), options.defaultLimit));
|
||||||
|
return {
|
||||||
|
offset: nonNegativeInteger(url.searchParams.get("offset"), 0),
|
||||||
|
limit,
|
||||||
|
maxLimit: options.maxLimit
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -261,34 +261,39 @@ export class PostgresProjectManagementStore {
|
|||||||
await this.ensureSchema();
|
await this.ensureSchema();
|
||||||
const params = [];
|
const params = [];
|
||||||
const clauses = [];
|
const clauses = [];
|
||||||
for (const [column, value] of [["source_id", filters.sourceId], ["file_ref", filters.fileRef], ["project_id", filters.projectId]]) {
|
appendTaskFilterClauses(params, clauses, filters);
|
||||||
if (!value) continue;
|
let sql = `SELECT task_ref, project_id, source_id, file_ref, task_id, title, status, parent_task_ref, depth, line_number, ordinal, link_count, source_fingerprint, task_json, updated_at
|
||||||
params.push(value);
|
FROM mdtodo_task_projection ${clauses.length ? `WHERE ${clauses.join(" AND ")}` : ""}
|
||||||
clauses.push(`${column} = $${params.length}`);
|
ORDER BY project_id, source_id, file_ref, ordinal`;
|
||||||
|
if (Number.isInteger(filters.limit) && filters.limit > 0) {
|
||||||
|
params.push(filters.limit);
|
||||||
|
sql += ` LIMIT $${params.length}`;
|
||||||
|
}
|
||||||
|
if (Number.isInteger(filters.offset) && filters.offset > 0) {
|
||||||
|
params.push(filters.offset);
|
||||||
|
sql += ` OFFSET $${params.length}`;
|
||||||
}
|
}
|
||||||
const result = await this.pool.query(
|
const result = await this.pool.query(
|
||||||
`SELECT task_ref, project_id, source_id, file_ref, task_id, title, status, parent_task_ref, depth, line_number, ordinal, link_count, source_fingerprint, task_json, updated_at
|
sql,
|
||||||
FROM mdtodo_task_projection ${clauses.length ? `WHERE ${clauses.join(" AND ")}` : ""}
|
|
||||||
ORDER BY project_id, source_id, file_ref, ordinal`,
|
|
||||||
params
|
params
|
||||||
);
|
);
|
||||||
return result.rows.map((row) => ({
|
return result.rows.map(taskFromRow);
|
||||||
taskRef: row.task_ref,
|
}
|
||||||
projectId: row.project_id,
|
|
||||||
sourceId: row.source_id,
|
async countTasks(filters = {}) {
|
||||||
fileRef: row.file_ref,
|
await this.ensureSchema();
|
||||||
taskId: row.task_id,
|
const params = [];
|
||||||
title: row.title,
|
const clauses = [];
|
||||||
status: row.status,
|
appendTaskFilterClauses(params, clauses, filters);
|
||||||
parentTaskRef: row.parent_task_ref,
|
const result = await this.pool.query(
|
||||||
depth: row.depth,
|
`SELECT COUNT(*)::int AS total FROM mdtodo_task_projection ${clauses.length ? `WHERE ${clauses.join(" AND ")}` : ""}`,
|
||||||
lineNumber: row.line_number,
|
params
|
||||||
ordinal: row.ordinal,
|
);
|
||||||
linkCount: row.link_count,
|
return Number(result.rows[0]?.total ?? 0);
|
||||||
sourceFingerprint: row.source_fingerprint,
|
}
|
||||||
updatedAt: row.updated_at,
|
|
||||||
task: row.task_json ?? {}
|
async getTask(taskRef) {
|
||||||
}));
|
return (await this.listTasks({ taskRef, limit: 1 }))[0] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async listWorkbenchLinks(filters = {}) {
|
async listWorkbenchLinks(filters = {}) {
|
||||||
@@ -378,7 +383,16 @@ class MemoryProjectManagementStore {
|
|||||||
async getSource(sourceId) { return this.sources.get(sourceId) ?? null; }
|
async getSource(sourceId) { return this.sources.get(sourceId) ?? null; }
|
||||||
async listDocuments(filters = {}) { return this.documents.filter((item) => !filters.sourceId || item.sourceId === filters.sourceId); }
|
async listDocuments(filters = {}) { return this.documents.filter((item) => !filters.sourceId || item.sourceId === filters.sourceId); }
|
||||||
async listTasks(filters = {}) {
|
async listTasks(filters = {}) {
|
||||||
return this.tasks.filter((item) => (!filters.sourceId || item.sourceId === filters.sourceId) && (!filters.fileRef || item.fileRef === filters.fileRef) && (!filters.projectId || item.projectId === filters.projectId));
|
const offset = Number.isInteger(filters.offset) && filters.offset > 0 ? filters.offset : 0;
|
||||||
|
const limit = Number.isInteger(filters.limit) && filters.limit > 0 ? filters.limit : null;
|
||||||
|
const rows = filterMemoryTasks(this.tasks, filters).sort(compareProjectTasks);
|
||||||
|
return limit ? rows.slice(offset, offset + limit) : rows.slice(offset);
|
||||||
|
}
|
||||||
|
async countTasks(filters = {}) {
|
||||||
|
return filterMemoryTasks(this.tasks, filters).length;
|
||||||
|
}
|
||||||
|
async getTask(taskRef) {
|
||||||
|
return this.tasks.find((task) => task.taskRef === taskRef) ?? null;
|
||||||
}
|
}
|
||||||
async listWorkbenchLinks(filters = {}) {
|
async listWorkbenchLinks(filters = {}) {
|
||||||
return this.links.filter((item) => (!filters.taskRef || item.taskRef === filters.taskRef) && (!filters.projectId || item.projectId === filters.projectId) && (!filters.sessionId || item.sessionId === filters.sessionId));
|
return this.links.filter((item) => (!filters.taskRef || item.taskRef === filters.taskRef) && (!filters.projectId || item.projectId === filters.projectId) && (!filters.sessionId || item.sessionId === filters.sessionId));
|
||||||
@@ -419,6 +433,8 @@ class UnconfiguredProjectManagementStore {
|
|||||||
async listSources() { await this.ensureSchema(); }
|
async listSources() { await this.ensureSchema(); }
|
||||||
async listDocuments() { await this.ensureSchema(); }
|
async listDocuments() { await this.ensureSchema(); }
|
||||||
async listTasks() { await this.ensureSchema(); }
|
async listTasks() { await this.ensureSchema(); }
|
||||||
|
async countTasks() { await this.ensureSchema(); }
|
||||||
|
async getTask() { await this.ensureSchema(); }
|
||||||
async listWorkbenchLinks() { await this.ensureSchema(); }
|
async listWorkbenchLinks() { await this.ensureSchema(); }
|
||||||
async upsertWorkbenchLink() { await this.ensureSchema(); }
|
async upsertWorkbenchLink() { await this.ensureSchema(); }
|
||||||
async recordAuditEvent() { await this.ensureSchema(); }
|
async recordAuditEvent() { await this.ensureSchema(); }
|
||||||
@@ -438,3 +454,61 @@ function workbenchLinkFromRow(row) {
|
|||||||
link: row.link_json ?? {}
|
link: row.link_json ?? {}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function appendTaskFilterClauses(params, clauses, filters = {}) {
|
||||||
|
for (const [column, value] of [["task_ref", filters.taskRef], ["source_id", filters.sourceId], ["file_ref", filters.fileRef], ["project_id", filters.projectId], ["parent_task_ref", filters.parentTaskRef], ["status", filters.status]]) {
|
||||||
|
if (!value) continue;
|
||||||
|
params.push(value);
|
||||||
|
clauses.push(`${column} = $${params.length}`);
|
||||||
|
}
|
||||||
|
const search = String(filters.search ?? "").trim();
|
||||||
|
if (search) {
|
||||||
|
params.push(`%${escapeSqlLike(search)}%`);
|
||||||
|
clauses.push(`(task_id ILIKE $${params.length} ESCAPE '\\' OR title ILIKE $${params.length} ESCAPE '\\' OR task_ref ILIKE $${params.length} ESCAPE '\\')`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function taskFromRow(row) {
|
||||||
|
return {
|
||||||
|
taskRef: row.task_ref,
|
||||||
|
projectId: row.project_id,
|
||||||
|
sourceId: row.source_id,
|
||||||
|
fileRef: row.file_ref,
|
||||||
|
taskId: row.task_id,
|
||||||
|
title: row.title,
|
||||||
|
status: row.status,
|
||||||
|
parentTaskRef: row.parent_task_ref,
|
||||||
|
depth: row.depth,
|
||||||
|
lineNumber: row.line_number,
|
||||||
|
ordinal: row.ordinal,
|
||||||
|
linkCount: row.link_count,
|
||||||
|
sourceFingerprint: row.source_fingerprint,
|
||||||
|
updatedAt: row.updated_at,
|
||||||
|
task: row.task_json ?? {}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterMemoryTasks(tasks, filters = {}) {
|
||||||
|
const search = String(filters.search ?? "").trim().toLowerCase();
|
||||||
|
return tasks.filter((item) => {
|
||||||
|
if (filters.taskRef && item.taskRef !== filters.taskRef) return false;
|
||||||
|
if (filters.sourceId && item.sourceId !== filters.sourceId) return false;
|
||||||
|
if (filters.fileRef && item.fileRef !== filters.fileRef) return false;
|
||||||
|
if (filters.projectId && item.projectId !== filters.projectId) return false;
|
||||||
|
if (filters.parentTaskRef && item.parentTaskRef !== filters.parentTaskRef) return false;
|
||||||
|
if (filters.status && item.status !== filters.status) return false;
|
||||||
|
if (search && ![item.taskId, item.title, item.taskRef].some((value) => String(value ?? "").toLowerCase().includes(search))) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareProjectTasks(a, b) {
|
||||||
|
return String(a.projectId ?? "").localeCompare(String(b.projectId ?? ""))
|
||||||
|
|| String(a.sourceId ?? "").localeCompare(String(b.sourceId ?? ""))
|
||||||
|
|| String(a.fileRef ?? "").localeCompare(String(b.fileRef ?? ""))
|
||||||
|
|| Number(a.ordinal ?? 0) - Number(b.ordinal ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeSqlLike(value) {
|
||||||
|
return String(value).replace(/[\\%_]/gu, "\\$&");
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export { apiKeysAPI } from "./apiKeys";
|
|||||||
export { usageAPI } from "./usage";
|
export { usageAPI } from "./usage";
|
||||||
export { billingAPI } from "./billing";
|
export { billingAPI } from "./billing";
|
||||||
export { systemAPI, type SkillUploadFileInput } from "./system";
|
export { systemAPI, type SkillUploadFileInput } from "./system";
|
||||||
export { projectManagementAPI, type MdtodoFileRecord, type MdtodoTaskMutationInput, type MdtodoTaskMutationResponse, type MdtodoTaskRecord, type ProjectNavigationResponse, type ProjectRecord, type ProjectSource, type ProjectSourceInput, type ProjectWorkbenchLinkRecord } from "./projectManagement";
|
export { projectManagementAPI, type MdtodoFileRecord, 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 { fetchJson } from "./client";
|
||||||
import { accessAPI } from "./access";
|
import { accessAPI } from "./access";
|
||||||
|
|||||||
@@ -135,7 +135,14 @@ export interface ProjectSourceMutationResponse extends ProjectManagementEnvelope
|
|||||||
export interface ProjectSourceProbeResponse extends ProjectManagementEnvelope { probe?: { ok?: boolean; status?: string; sourceId?: string; sourceKind?: string; entries?: number; valuesRedacted?: boolean } }
|
export interface ProjectSourceProbeResponse extends ProjectManagementEnvelope { probe?: { ok?: boolean; status?: string; sourceId?: string; sourceKind?: string; entries?: number; valuesRedacted?: boolean } }
|
||||||
export interface ProjectSourceReindexResponse extends ProjectManagementEnvelope { projection?: ProjectProjectionSummary & { sourceKind?: string; rootRef?: string; valuesRedacted?: boolean } }
|
export interface ProjectSourceReindexResponse extends ProjectManagementEnvelope { projection?: ProjectProjectionSummary & { sourceKind?: string; rootRef?: string; valuesRedacted?: boolean } }
|
||||||
export interface MdtodoFileListResponse extends ProjectManagementEnvelope { files?: MdtodoFileRecord[] }
|
export interface MdtodoFileListResponse extends ProjectManagementEnvelope { files?: MdtodoFileRecord[] }
|
||||||
export interface MdtodoTaskListResponse extends ProjectManagementEnvelope { tasks?: MdtodoTaskRecord[] }
|
export interface MdtodoTaskPage {
|
||||||
|
offset?: number;
|
||||||
|
limit?: number;
|
||||||
|
maxLimit?: number;
|
||||||
|
total?: number;
|
||||||
|
hasMore?: boolean;
|
||||||
|
}
|
||||||
|
export interface MdtodoTaskListResponse extends ProjectManagementEnvelope { tasks?: MdtodoTaskRecord[]; page?: MdtodoTaskPage }
|
||||||
export interface MdtodoTaskMutationInput {
|
export interface MdtodoTaskMutationInput {
|
||||||
sourceId?: string;
|
sourceId?: string;
|
||||||
fileRef?: string;
|
fileRef?: string;
|
||||||
@@ -160,6 +167,11 @@ export interface MdtodoTaskQuery {
|
|||||||
sourceId?: string | null;
|
sourceId?: string | null;
|
||||||
fileRef?: string | null;
|
fileRef?: string | null;
|
||||||
projectId?: string | null;
|
projectId?: string | null;
|
||||||
|
parentTaskRef?: string | null;
|
||||||
|
status?: string | null;
|
||||||
|
search?: string | null;
|
||||||
|
limit?: number | null;
|
||||||
|
offset?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProjectWorkbenchLinkQuery {
|
export interface ProjectWorkbenchLinkQuery {
|
||||||
@@ -177,17 +189,17 @@ export const projectManagementAPI = {
|
|||||||
probeSource: (sourceId: string): Promise<ApiResult<ProjectSourceProbeResponse>> => fetchJson(`/v1/project-management/mdtodo/sources/${encodeURIComponent(sourceId)}/probe`, { method: "POST", body: JSON.stringify({}), timeoutMs: 12000, timeoutName: "project mdtodo source probe" }),
|
probeSource: (sourceId: string): Promise<ApiResult<ProjectSourceProbeResponse>> => fetchJson(`/v1/project-management/mdtodo/sources/${encodeURIComponent(sourceId)}/probe`, { method: "POST", body: JSON.stringify({}), timeoutMs: 12000, timeoutName: "project mdtodo source probe" }),
|
||||||
reindexSource: (sourceId: string): Promise<ApiResult<ProjectSourceReindexResponse>> => fetchJson(`/v1/project-management/mdtodo/sources/${encodeURIComponent(sourceId)}/reindex`, { method: "POST", body: JSON.stringify({}), timeoutMs: 12000, timeoutName: "project mdtodo source reindex" }),
|
reindexSource: (sourceId: string): Promise<ApiResult<ProjectSourceReindexResponse>> => fetchJson(`/v1/project-management/mdtodo/sources/${encodeURIComponent(sourceId)}/reindex`, { method: "POST", body: JSON.stringify({}), timeoutMs: 12000, timeoutName: "project mdtodo source reindex" }),
|
||||||
files: (sourceId?: string | null): Promise<ApiResult<MdtodoFileListResponse>> => fetchJson(`/v1/project-management/mdtodo/files${queryString({ sourceId })}`, { timeoutMs: 12000, timeoutName: "project mdtodo files" }),
|
files: (sourceId?: string | null): Promise<ApiResult<MdtodoFileListResponse>> => fetchJson(`/v1/project-management/mdtodo/files${queryString({ sourceId })}`, { timeoutMs: 12000, timeoutName: "project mdtodo files" }),
|
||||||
tasks: (query: MdtodoTaskQuery = {}): Promise<ApiResult<MdtodoTaskListResponse>> => fetchJson(`/v1/project-management/mdtodo/tasks${queryString({ sourceId: query.sourceId, fileRef: query.fileRef, projectId: query.projectId })}`, { timeoutMs: 12000, timeoutName: "project mdtodo tasks" }),
|
tasks: (query: MdtodoTaskQuery = {}): Promise<ApiResult<MdtodoTaskListResponse>> => fetchJson(`/v1/project-management/mdtodo/tasks${queryString({ sourceId: query.sourceId, fileRef: query.fileRef, projectId: query.projectId, parentTaskRef: query.parentTaskRef, status: query.status, search: query.search, limit: query.limit, offset: query.offset })}`, { timeoutMs: 12000, timeoutName: "project mdtodo tasks" }),
|
||||||
updateTask: (taskRef: string, input: MdtodoTaskMutationInput): Promise<ApiResult<MdtodoTaskMutationResponse>> => fetchJson(`/v1/project-management/mdtodo/tasks/${encodeURIComponent(taskRef)}`, { method: "PATCH", body: JSON.stringify(input), timeoutMs: 12000, timeoutName: "project mdtodo task update" }),
|
updateTask: (taskRef: string, input: MdtodoTaskMutationInput): Promise<ApiResult<MdtodoTaskMutationResponse>> => fetchJson(`/v1/project-management/mdtodo/tasks/${encodeURIComponent(taskRef)}`, { method: "PATCH", body: JSON.stringify(input), timeoutMs: 12000, timeoutName: "project mdtodo task update" }),
|
||||||
createTask: (input: MdtodoTaskMutationInput): Promise<ApiResult<MdtodoTaskMutationResponse>> => fetchJson("/v1/project-management/mdtodo/tasks", { method: "POST", body: JSON.stringify(input), timeoutMs: 12000, timeoutName: "project mdtodo task create" }),
|
createTask: (input: MdtodoTaskMutationInput): Promise<ApiResult<MdtodoTaskMutationResponse>> => fetchJson("/v1/project-management/mdtodo/tasks", { method: "POST", body: JSON.stringify(input), timeoutMs: 12000, timeoutName: "project mdtodo task create" }),
|
||||||
deleteTask: (taskRef: string, input: MdtodoTaskMutationInput): Promise<ApiResult<MdtodoTaskMutationResponse>> => fetchJson(`/v1/project-management/mdtodo/tasks/${encodeURIComponent(taskRef)}`, { method: "DELETE", body: JSON.stringify(input), timeoutMs: 12000, timeoutName: "project mdtodo task delete" }),
|
deleteTask: (taskRef: string, input: MdtodoTaskMutationInput): Promise<ApiResult<MdtodoTaskMutationResponse>> => fetchJson(`/v1/project-management/mdtodo/tasks/${encodeURIComponent(taskRef)}`, { method: "DELETE", body: JSON.stringify(input), timeoutMs: 12000, timeoutName: "project mdtodo task delete" }),
|
||||||
workbenchLinks: (query: ProjectWorkbenchLinkQuery = {}): Promise<ApiResult<ProjectWorkbenchLinkListResponse>> => fetchJson(`/v1/project-management/workbench-links${queryString({ taskRef: query.taskRef, projectId: query.projectId, sessionId: query.sessionId })}`, { timeoutMs: 12000, timeoutName: "project workbench links" })
|
workbenchLinks: (query: ProjectWorkbenchLinkQuery = {}): Promise<ApiResult<ProjectWorkbenchLinkListResponse>> => fetchJson(`/v1/project-management/workbench-links${queryString({ taskRef: query.taskRef, projectId: query.projectId, sessionId: query.sessionId })}`, { timeoutMs: 12000, timeoutName: "project workbench links" })
|
||||||
};
|
};
|
||||||
|
|
||||||
function queryString(values: Record<string, string | null | undefined>): string {
|
function queryString(values: Record<string, string | number | null | undefined>): string {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
for (const [key, value] of Object.entries(values)) {
|
for (const [key, value] of Object.entries(values)) {
|
||||||
if (value) params.set(key, value);
|
if (value !== null && value !== undefined && value !== "") params.set(key, String(value));
|
||||||
}
|
}
|
||||||
const text = params.toString();
|
const text = params.toString();
|
||||||
return text ? `?${text}` : "";
|
return text ? `?${text}` : "";
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, reactive, ref, watch } from "vue";
|
import { computed, onMounted, reactive, ref, watch } from "vue";
|
||||||
import { useRouter } from "vue-router";
|
import { useRouter } from "vue-router";
|
||||||
import { projectManagementAPI, workbenchAPI, type MdtodoFileRecord, type MdtodoTaskMutationInput, type MdtodoTaskRecord, type ProjectNavigationResponse, type ProjectSource, type ProjectSourceInput, type ProjectWorkbenchLinkRecord } from "@/api";
|
import { projectManagementAPI, workbenchAPI, type MdtodoFileRecord, type MdtodoTaskMutationInput, type MdtodoTaskPage, type MdtodoTaskRecord, type ProjectNavigationResponse, type ProjectSource, type ProjectSourceInput, type ProjectWorkbenchLinkRecord } from "@/api";
|
||||||
import EmptyState from "@/components/common/EmptyState.vue";
|
import EmptyState from "@/components/common/EmptyState.vue";
|
||||||
import LoadingState from "@/components/common/LoadingState.vue";
|
import LoadingState from "@/components/common/LoadingState.vue";
|
||||||
import PageHeader from "@/components/common/PageHeader.vue";
|
import PageHeader from "@/components/common/PageHeader.vue";
|
||||||
@@ -33,6 +33,7 @@ const navigation = ref<ProjectNavigationResponse | null>(null);
|
|||||||
const sources = ref<ProjectSource[]>([]);
|
const sources = ref<ProjectSource[]>([]);
|
||||||
const files = ref<MdtodoFileRecord[]>([]);
|
const files = ref<MdtodoFileRecord[]>([]);
|
||||||
const tasks = ref<MdtodoTaskRecord[]>([]);
|
const tasks = ref<MdtodoTaskRecord[]>([]);
|
||||||
|
const taskPage = ref<MdtodoTaskPage | null>(null);
|
||||||
const links = ref<ProjectWorkbenchLinkRecord[]>([]);
|
const links = ref<ProjectWorkbenchLinkRecord[]>([]);
|
||||||
const selectedSourceId = ref<string | null>(null);
|
const selectedSourceId = ref<string | null>(null);
|
||||||
const selectedFileRef = ref<string | null>(null);
|
const selectedFileRef = ref<string | null>(null);
|
||||||
@@ -59,6 +60,8 @@ const sourceForm = reactive<ProjectSourceInput>({
|
|||||||
maxFiles: 120
|
maxFiles: 120
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const taskWindowLimit = 200;
|
||||||
|
|
||||||
const selectedSource = computed(() => sources.value.find((source) => source.sourceId === selectedSourceId.value) ?? null);
|
const selectedSource = computed(() => sources.value.find((source) => source.sourceId === selectedSourceId.value) ?? null);
|
||||||
const selectedFile = computed(() => files.value.find((file) => file.fileRef === selectedFileRef.value) ?? null);
|
const selectedFile = computed(() => files.value.find((file) => file.fileRef === selectedFileRef.value) ?? null);
|
||||||
const selectedTask = computed(() => tasks.value.find((task) => task.taskRef === selectedTaskRef.value) ?? null);
|
const selectedTask = computed(() => tasks.value.find((task) => task.taskRef === selectedTaskRef.value) ?? null);
|
||||||
@@ -140,19 +143,11 @@ async function loadPage(): Promise<void> {
|
|||||||
async function loadFilesAndTasks(sourceId: string): Promise<void> {
|
async function loadFilesAndTasks(sourceId: string): Promise<void> {
|
||||||
taskLoading.value = true;
|
taskLoading.value = true;
|
||||||
try {
|
try {
|
||||||
const [fileResponse, taskResponse] = await Promise.all([
|
const fileResponse = await projectManagementAPI.files(sourceId);
|
||||||
projectManagementAPI.files(sourceId),
|
|
||||||
projectManagementAPI.tasks({ sourceId })
|
|
||||||
]);
|
|
||||||
if (!fileResponse.ok) throw fileResponse;
|
if (!fileResponse.ok) throw fileResponse;
|
||||||
if (!taskResponse.ok) throw taskResponse;
|
|
||||||
files.value = fileResponse.data?.files ?? [];
|
files.value = fileResponse.data?.files ?? [];
|
||||||
tasks.value = taskResponse.data?.tasks ?? [];
|
|
||||||
selectedFileRef.value = selectedFileRef.value && files.value.some((file) => file.fileRef === selectedFileRef.value) ? selectedFileRef.value : files.value[0]?.fileRef ?? null;
|
selectedFileRef.value = selectedFileRef.value && files.value.some((file) => file.fileRef === selectedFileRef.value) ? selectedFileRef.value : files.value[0]?.fileRef ?? null;
|
||||||
const firstVisibleTask = selectedFileRef.value ? tasks.value.find((task) => task.fileRef === selectedFileRef.value) : tasks.value[0];
|
await loadTaskWindow(sourceId, selectedFileRef.value, selectedTaskRef.value);
|
||||||
selectedTaskRef.value = firstVisibleTask?.taskRef ?? null;
|
|
||||||
collapsedTaskRefs.value = new Set();
|
|
||||||
if (!selectedTaskRef.value) await loadLinks(null);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err);
|
setError(err);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -178,13 +173,20 @@ function onSourceSelect(event: Event): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onFileSelect(event: Event): void {
|
function onFileSelect(event: Event): void {
|
||||||
selectFile((event.target as HTMLSelectElement).value);
|
void selectFile((event.target as HTMLSelectElement).value);
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectFile(fileRef: string): void {
|
async function selectFile(fileRef: string): Promise<void> {
|
||||||
selectedFileRef.value = fileRef || null;
|
selectedFileRef.value = fileRef || null;
|
||||||
const nextTask = tasks.value.find((task) => task.fileRef === fileRef) ?? null;
|
if (!selectedSourceId.value) return;
|
||||||
selectedTaskRef.value = nextTask?.taskRef ?? null;
|
taskLoading.value = true;
|
||||||
|
try {
|
||||||
|
await loadTaskWindow(selectedSourceId.value, selectedFileRef.value, null);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err);
|
||||||
|
} finally {
|
||||||
|
taskLoading.value = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectTask(taskRef: string): void {
|
function selectTask(taskRef: string): void {
|
||||||
@@ -357,6 +359,17 @@ async function runTaskMutation(action: () => ReturnType<typeof projectManagement
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadTaskWindow(sourceId: string, fileRef: string | null, preferredTaskRef?: string | null): Promise<void> {
|
||||||
|
const taskResponse = await projectManagementAPI.tasks({ sourceId, fileRef, limit: taskWindowLimit });
|
||||||
|
if (!taskResponse.ok) throw taskResponse;
|
||||||
|
tasks.value = taskResponse.data?.tasks ?? [];
|
||||||
|
taskPage.value = taskResponse.data?.page ?? null;
|
||||||
|
const preferredTask = preferredTaskRef ? tasks.value.find((task) => task.taskRef === preferredTaskRef) : null;
|
||||||
|
selectedTaskRef.value = preferredTask?.taskRef ?? tasks.value[0]?.taskRef ?? null;
|
||||||
|
collapsedTaskRefs.value = new Set();
|
||||||
|
if (!selectedTaskRef.value) await loadLinks(null);
|
||||||
|
}
|
||||||
|
|
||||||
function taskFingerprint(): string | undefined {
|
function taskFingerprint(): string | undefined {
|
||||||
return selectedTask.value?.sourceFingerprint || selectedFile.value?.fingerprint || undefined;
|
return selectedTask.value?.sourceFingerprint || selectedFile.value?.fingerprint || undefined;
|
||||||
}
|
}
|
||||||
@@ -479,7 +492,7 @@ function displayDate(value?: string | null): string {
|
|||||||
<section class="mdtodo-workspace">
|
<section class="mdtodo-workspace">
|
||||||
<section class="data-panel mdtodo-task-panel" data-testid="mdtodo-task-tree">
|
<section class="data-panel mdtodo-task-panel" data-testid="mdtodo-task-tree">
|
||||||
<header class="mdtodo-panel-header">
|
<header class="mdtodo-panel-header">
|
||||||
<div><h2>任务树</h2><p>{{ filteredTasks.length }} / {{ fileScopedTasks.length }} tasks</p></div>
|
<div><h2>任务树</h2><p>{{ filteredTasks.length }} / {{ taskPage?.total ?? fileScopedTasks.length }} tasks<span v-if="taskPage?.hasMore"> · window {{ taskPage.limit }}</span></p></div>
|
||||||
<div class="task-tools">
|
<div class="task-tools">
|
||||||
<input v-model="taskSearch" data-testid="mdtodo-task-search" type="search" placeholder="R1 / title" aria-label="搜索任务" />
|
<input v-model="taskSearch" data-testid="mdtodo-task-search" type="search" placeholder="R1 / title" aria-label="搜索任务" />
|
||||||
<select v-model="taskStatusFilter" data-testid="mdtodo-task-status-filter" aria-label="筛选状态">
|
<select v-model="taskStatusFilter" data-testid="mdtodo-task-status-filter" aria-label="筛选状态">
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ async function loadProjects(): Promise<void> {
|
|||||||
projectManagementAPI.navigation(),
|
projectManagementAPI.navigation(),
|
||||||
projectManagementAPI.projects(),
|
projectManagementAPI.projects(),
|
||||||
projectManagementAPI.sources(),
|
projectManagementAPI.sources(),
|
||||||
projectManagementAPI.tasks(),
|
projectManagementAPI.tasks({ limit: 6 }),
|
||||||
projectManagementAPI.workbenchLinks()
|
projectManagementAPI.workbenchLinks()
|
||||||
]);
|
]);
|
||||||
for (const response of [navigationResponse, projectResponse, sourceResponse, taskResponse, linkResponse]) {
|
for (const response of [navigationResponse, projectResponse, sourceResponse, taskResponse, linkResponse]) {
|
||||||
|
|||||||
Reference in New Issue
Block a user