|
|
|
@@ -0,0 +1,327 @@
|
|
|
|
|
import React from "react";
|
|
|
|
|
import { fmtClock, fmtDate } from "./time";
|
|
|
|
|
import { LoadingTitle } from "./loading-indicator";
|
|
|
|
|
import { errorMessage, requestJson } from "./unidesk-error";
|
|
|
|
|
import { UniDeskErrorBanner } from "./unidesk-error-banner";
|
|
|
|
|
|
|
|
|
|
type AnyRecord = Record<string, any>;
|
|
|
|
|
|
|
|
|
|
const h = React.createElement;
|
|
|
|
|
const { useEffect, useMemo } = React;
|
|
|
|
|
const useState: any = React.useState;
|
|
|
|
|
|
|
|
|
|
function StatusBadge({ status, children }: AnyRecord) {
|
|
|
|
|
const normalized = String(status || "unknown").toLowerCase();
|
|
|
|
|
return h("span", { className: `status-badge ${normalized}` }, children || status || "unknown");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function MetricCard({ label, value, hint, tone }: AnyRecord) {
|
|
|
|
|
return h("article", { className: `metric-card ${tone || ""}` },
|
|
|
|
|
h("div", { className: "metric-label" }, label),
|
|
|
|
|
h("div", { className: "metric-value" }, value),
|
|
|
|
|
h("div", { className: "metric-hint" }, hint),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function Panel({ title, eyebrow, actions, children, className, loading }: AnyRecord) {
|
|
|
|
|
return h("section", { className: `panel ${className || ""}` },
|
|
|
|
|
h("div", { className: "panel-head" },
|
|
|
|
|
h("div", null,
|
|
|
|
|
eyebrow ? h("p", { className: "panel-eyebrow" }, eyebrow) : null,
|
|
|
|
|
h(LoadingTitle, { title, loading }),
|
|
|
|
|
),
|
|
|
|
|
actions ? h("div", { className: "panel-actions" }, actions) : null,
|
|
|
|
|
),
|
|
|
|
|
h("div", { className: "panel-body" }, children),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function RawButton({ title, data, onOpen, testId }: AnyRecord) {
|
|
|
|
|
return h("button", {
|
|
|
|
|
type: "button",
|
|
|
|
|
className: "ghost-btn",
|
|
|
|
|
"data-testid": testId,
|
|
|
|
|
onClick: () => onOpen(title, data),
|
|
|
|
|
}, "查看原始JSON");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function EmptyState({ title, text }: AnyRecord) {
|
|
|
|
|
return h("div", { className: "empty-state" }, h("strong", null, title), h("span", null, text));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function microserviceRuntime(service: any): AnyRecord {
|
|
|
|
|
return service?.runtime && typeof service.runtime === "object" && !Array.isArray(service.runtime) ? service.runtime : {};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function microserviceBackend(service: any): AnyRecord {
|
|
|
|
|
return service?.backend && typeof service.backend === "object" && !Array.isArray(service.backend) ? service.backend : {};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function microserviceRepository(service: any): AnyRecord {
|
|
|
|
|
return service?.repository && typeof service.repository === "object" && !Array.isArray(service.repository) ? service.repository : {};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function mdtodoApi(apiBaseUrl: string, path: string): string {
|
|
|
|
|
return `${apiBaseUrl}/microservices/mdtodo/proxy${path}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function statusLabel(status: string): string {
|
|
|
|
|
if (status === "completed") return "已完成";
|
|
|
|
|
if (status === "in_progress") return "进行中";
|
|
|
|
|
return "待处理";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function statusTone(status: string): string {
|
|
|
|
|
if (status === "completed") return "online";
|
|
|
|
|
if (status === "in_progress") return "warn";
|
|
|
|
|
return "unknown";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function flattenTasks(tasks: any[]): any[] {
|
|
|
|
|
const rows: any[] = [];
|
|
|
|
|
const visit = (items: any[]) => {
|
|
|
|
|
for (const task of items) {
|
|
|
|
|
rows.push(task);
|
|
|
|
|
if (Array.isArray(task.children)) visit(task.children);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
visit(Array.isArray(tasks) ? tasks : []);
|
|
|
|
|
return rows;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function TaskTree({ tasks, selectedId, onSelect }: AnyRecord) {
|
|
|
|
|
if (!tasks.length) return h(EmptyState, { title: "暂无任务", text: "当前文件没有 R 编号任务。" });
|
|
|
|
|
const renderTask = (task: AnyRecord): React.ReactElement => h("li", { key: task.id },
|
|
|
|
|
h("button", {
|
|
|
|
|
type: "button",
|
|
|
|
|
className: `mdtodo-task-row ${selectedId === task.id ? "active" : ""}`,
|
|
|
|
|
style: { "--task-depth": Math.min(Number(task.depth || 0), 6) },
|
|
|
|
|
onClick: () => onSelect(task.id),
|
|
|
|
|
"data-testid": `mdtodo-task-${String(task.id).replace(/[^A-Za-z0-9_-]+/g, "-")}`,
|
|
|
|
|
},
|
|
|
|
|
h(StatusBadge, { status: statusTone(task.status) }, statusLabel(task.status)),
|
|
|
|
|
h("code", null, task.id),
|
|
|
|
|
h("span", { className: "mdtodo-task-title" }, task.title || "--"),
|
|
|
|
|
h("span", { className: "mdtodo-link-count" }, `${task.linkExists ?? 0}/${task.linkCount ?? 0}`),
|
|
|
|
|
),
|
|
|
|
|
Array.isArray(task.children) && task.children.length > 0 ? h("ol", null, task.children.map(renderTask)) : null,
|
|
|
|
|
);
|
|
|
|
|
return h("ol", { className: "mdtodo-tree" }, tasks.map(renderTask));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function MdtodoPage({ microservices, onRaw, apiBaseUrl = "/api" }: AnyRecord) {
|
|
|
|
|
const service = microservices.find((item: any) => item.id === "mdtodo") || null;
|
|
|
|
|
const [state, setState] = useState({ loading: false, saving: false, error: "", notice: "", health: null, files: [], todo: null, selectedFile: "", selectedTaskId: "", refreshedAt: null, command: null });
|
|
|
|
|
const [draftTitle, setDraftTitle] = useState("");
|
|
|
|
|
const [draftRaw, setDraftRaw] = useState("");
|
|
|
|
|
const [newTaskTitle, setNewTaskTitle] = useState("");
|
|
|
|
|
|
|
|
|
|
const tasks = Array.isArray(state.todo?.tasks) ? state.todo.tasks : [];
|
|
|
|
|
const flatTasks = useMemo(() => flattenTasks(tasks), [state.todo]);
|
|
|
|
|
const selectedTask = flatTasks.find((task: any) => task.id === state.selectedTaskId) || flatTasks[0] || null;
|
|
|
|
|
const stats = state.todo?.stats || {};
|
|
|
|
|
|
|
|
|
|
async function load(filePath = state.selectedFile): Promise<void> {
|
|
|
|
|
if (!service) return;
|
|
|
|
|
setState((prev: any) => ({ ...prev, loading: true, error: "", notice: "" }));
|
|
|
|
|
try {
|
|
|
|
|
const [health, fileList] = await Promise.all([
|
|
|
|
|
requestJson(`${apiBaseUrl}/microservices/mdtodo/health`),
|
|
|
|
|
requestJson(mdtodoApi(apiBaseUrl, "/api/files")),
|
|
|
|
|
]);
|
|
|
|
|
const files = Array.isArray(fileList.files) ? fileList.files : [];
|
|
|
|
|
const selectedFile = filePath || files[0]?.path || "";
|
|
|
|
|
const todo = selectedFile ? await requestJson(mdtodoApi(apiBaseUrl, `/api/tasks?file=${encodeURIComponent(selectedFile)}`)) : null;
|
|
|
|
|
const nextTasks = Array.isArray(todo?.tasks) ? flattenTasks(todo.tasks) : [];
|
|
|
|
|
const nextSelectedTaskId = nextTasks.some((task: any) => task.id === state.selectedTaskId)
|
|
|
|
|
? state.selectedTaskId
|
|
|
|
|
: nextTasks[0]?.id || "";
|
|
|
|
|
setState({ loading: false, saving: false, error: "", notice: "", health, files, todo, selectedFile, selectedTaskId: nextSelectedTaskId, refreshedAt: new Date(), command: state.command });
|
|
|
|
|
} catch (err) {
|
|
|
|
|
setState((prev: any) => ({ ...prev, loading: false, error: errorMessage(err, "MDTODO 加载失败") }));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
load();
|
|
|
|
|
}, [service?.id, service?.runtime?.providerStatus]);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
setDraftTitle(selectedTask?.title || "");
|
|
|
|
|
setDraftRaw(selectedTask?.rawContent || "");
|
|
|
|
|
}, [selectedTask?.id, state.selectedFile]);
|
|
|
|
|
|
|
|
|
|
if (!service) return h(EmptyState, { title: "MDTODO 未登记", text: "请在 config.json 的 microservices 中登记用户服务 id=mdtodo" });
|
|
|
|
|
|
|
|
|
|
const runtime = microserviceRuntime(service);
|
|
|
|
|
const repository = microserviceRepository(service);
|
|
|
|
|
const backend = microserviceBackend(service);
|
|
|
|
|
|
|
|
|
|
async function selectFile(path: string): Promise<void> {
|
|
|
|
|
setState((prev: any) => ({ ...prev, selectedFile: path, selectedTaskId: "", command: null }));
|
|
|
|
|
await load(path);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function patchSelected(patch: AnyRecord): Promise<void> {
|
|
|
|
|
if (!selectedTask || !state.selectedFile) return;
|
|
|
|
|
setState((prev: any) => ({ ...prev, saving: true, error: "", notice: "" }));
|
|
|
|
|
try {
|
|
|
|
|
const result = await requestJson(mdtodoApi(apiBaseUrl, `/api/tasks/${encodeURIComponent(selectedTask.id)}`), {
|
|
|
|
|
method: "PATCH",
|
|
|
|
|
body: { file: state.selectedFile, ...patch },
|
|
|
|
|
});
|
|
|
|
|
setState((prev: any) => ({ ...prev, saving: false, todo: result.file, notice: result.result?.message || "任务已更新" }));
|
|
|
|
|
} catch (err) {
|
|
|
|
|
setState((prev: any) => ({ ...prev, saving: false, error: errorMessage(err, "任务更新失败") }));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function saveSelected(): Promise<void> {
|
|
|
|
|
await patchSelected({ title: draftTitle, rawContent: draftRaw });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function addTask(parentId?: string): Promise<void> {
|
|
|
|
|
if (!state.selectedFile) return;
|
|
|
|
|
setState((prev: any) => ({ ...prev, saving: true, error: "", notice: "" }));
|
|
|
|
|
try {
|
|
|
|
|
const result = await requestJson(mdtodoApi(apiBaseUrl, "/api/tasks"), {
|
|
|
|
|
method: "POST",
|
|
|
|
|
body: { file: state.selectedFile, parentId, title: newTaskTitle || "新任务" },
|
|
|
|
|
});
|
|
|
|
|
setNewTaskTitle("");
|
|
|
|
|
setState((prev: any) => ({ ...prev, saving: false, todo: result.file, selectedTaskId: result.result?.taskId || prev.selectedTaskId, notice: result.result?.message || "任务已创建" }));
|
|
|
|
|
await load(state.selectedFile);
|
|
|
|
|
} catch (err) {
|
|
|
|
|
setState((prev: any) => ({ ...prev, saving: false, error: errorMessage(err, "创建任务失败") }));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function deleteSelected(): Promise<void> {
|
|
|
|
|
if (!selectedTask || !state.selectedFile) return;
|
|
|
|
|
if (!window.confirm(`删除 ${selectedTask.id} 及其子任务?`)) return;
|
|
|
|
|
setState((prev: any) => ({ ...prev, saving: true, error: "", notice: "" }));
|
|
|
|
|
try {
|
|
|
|
|
const result = await requestJson(mdtodoApi(apiBaseUrl, `/api/tasks/${encodeURIComponent(selectedTask.id)}?file=${encodeURIComponent(state.selectedFile)}`), { method: "DELETE" });
|
|
|
|
|
const nextTasks = flattenTasks(result.file?.tasks || []);
|
|
|
|
|
setState((prev: any) => ({ ...prev, saving: false, todo: result.file, selectedTaskId: nextTasks[0]?.id || "", notice: result.result?.message || "任务已删除" }));
|
|
|
|
|
} catch (err) {
|
|
|
|
|
setState((prev: any) => ({ ...prev, saving: false, error: errorMessage(err, "删除任务失败") }));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function generateCommand(): Promise<void> {
|
|
|
|
|
if (!selectedTask || !state.selectedFile) return;
|
|
|
|
|
setState((prev: any) => ({ ...prev, saving: true, error: "", command: null }));
|
|
|
|
|
try {
|
|
|
|
|
const command = await requestJson(mdtodoApi(apiBaseUrl, "/api/execute-command"), {
|
|
|
|
|
method: "POST",
|
|
|
|
|
body: { file: state.selectedFile, taskId: selectedTask.id, mode: "codex" },
|
|
|
|
|
});
|
|
|
|
|
setState((prev: any) => ({ ...prev, saving: false, command, notice: "执行命令已生成" }));
|
|
|
|
|
} catch (err) {
|
|
|
|
|
setState((prev: any) => ({ ...prev, saving: false, error: errorMessage(err, "执行命令生成失败") }));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return h("div", { className: "mdtodo-page", "data-testid": "mdtodo-page" },
|
|
|
|
|
h(Panel, {
|
|
|
|
|
title: "MDTODO 工作台",
|
|
|
|
|
eyebrow: "D601 k3s managed service",
|
|
|
|
|
loading: state.loading,
|
|
|
|
|
actions: h("div", { className: "panel-actions" },
|
|
|
|
|
h("button", { type: "button", className: "ghost-btn", onClick: () => void load(), disabled: state.loading, "data-testid": "mdtodo-refresh-button" }, state.loading ? "刷新中" : "刷新"),
|
|
|
|
|
h(RawButton, { title: "MDTODO 用户服务", data: service, onOpen: onRaw, testId: "raw-mdtodo-service" }),
|
|
|
|
|
),
|
|
|
|
|
},
|
|
|
|
|
h("div", { className: "findjob-hero" },
|
|
|
|
|
h("div", null,
|
|
|
|
|
h("div", { className: "node-version-line" },
|
|
|
|
|
h(StatusBadge, { status: runtime.providerStatus === "online" ? "online" : "warn" }, runtime.providerStatus || "unknown"),
|
|
|
|
|
h("span", null, service.providerId),
|
|
|
|
|
h("span", null, backend.proxyMode || "--"),
|
|
|
|
|
),
|
|
|
|
|
h("p", { className: "muted paragraph" }, service.description),
|
|
|
|
|
),
|
|
|
|
|
h("div", { className: "microservice-ref-card" },
|
|
|
|
|
h("span", null, "Repo"),
|
|
|
|
|
h("strong", null, repository.url || "--"),
|
|
|
|
|
h("code", null, repository.composeFile || "--"),
|
|
|
|
|
),
|
|
|
|
|
h("div", { className: "microservice-ref-card" },
|
|
|
|
|
h("span", null, "Workspace"),
|
|
|
|
|
h("strong", null, state.health?.rootDir || "/workspace"),
|
|
|
|
|
h("code", null, `${backend.nodeBindHost || "--"}:${backend.nodePort || "--"}`),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
h(UniDeskErrorBanner, { error: state.error, wide: true }),
|
|
|
|
|
state.notice ? h("div", { className: "notice-line" }, state.notice) : null,
|
|
|
|
|
),
|
|
|
|
|
h("div", { className: "metric-grid" },
|
|
|
|
|
h(MetricCard, { label: "TODO 文件", value: state.files.length, hint: state.health?.rootExists ? "hostPath ready" : "hostPath missing", tone: state.health?.ok ? "ok" : "warn" }),
|
|
|
|
|
h(MetricCard, { label: "任务总数", value: stats.total ?? "--", hint: state.selectedFile || "--" }),
|
|
|
|
|
h(MetricCard, { label: "已完成", value: stats.completed ?? "--", hint: "completed", tone: "ok" }),
|
|
|
|
|
h(MetricCard, { label: "进行中", value: stats.inProgress ?? "--", hint: "in progress", tone: "warn" }),
|
|
|
|
|
h(MetricCard, { label: "待处理", value: stats.pending ?? "--", hint: state.refreshedAt ? fmtClock(state.refreshedAt) : "pending" }),
|
|
|
|
|
),
|
|
|
|
|
h("div", { className: "mdtodo-layout" },
|
|
|
|
|
h(Panel, { title: "文件", eyebrow: `${state.files.length} Markdown`, loading: state.loading, className: "mdtodo-file-panel" },
|
|
|
|
|
state.files.length === 0 ? h(EmptyState, { title: "暂无 TODO 文件", text: "等待后端扫描 MDTODO 工作区。" }) :
|
|
|
|
|
h("div", { className: "mdtodo-file-list" }, state.files.map((file: any) => h("button", {
|
|
|
|
|
key: file.path,
|
|
|
|
|
type: "button",
|
|
|
|
|
className: `mdtodo-file-item ${state.selectedFile === file.path ? "active" : ""}`,
|
|
|
|
|
onClick: () => void selectFile(file.path),
|
|
|
|
|
},
|
|
|
|
|
h("strong", null, file.name),
|
|
|
|
|
h("span", null, file.directory || "."),
|
|
|
|
|
h("code", null, `${file.stats?.total ?? "--"} tasks / ${fmtDate(file.mtime)}`),
|
|
|
|
|
))),
|
|
|
|
|
),
|
|
|
|
|
h(Panel, {
|
|
|
|
|
title: "任务树",
|
|
|
|
|
eyebrow: state.selectedFile || "Tasks",
|
|
|
|
|
loading: state.loading,
|
|
|
|
|
actions: h("div", { className: "panel-actions" },
|
|
|
|
|
h("input", { value: newTaskTitle, onChange: (event: any) => setNewTaskTitle(event.target.value), placeholder: "新任务标题", "data-testid": "mdtodo-new-title" }),
|
|
|
|
|
h("button", { type: "button", className: "ghost-btn", onClick: () => void addTask(), disabled: state.saving || !state.selectedFile }, "新增"),
|
|
|
|
|
selectedTask ? h("button", { type: "button", className: "ghost-btn", onClick: () => void addTask(selectedTask.id), disabled: state.saving }, "新增子任务") : null,
|
|
|
|
|
state.todo ? h(RawButton, { title: "MDTODO 当前文件", data: state.todo, onOpen: onRaw, testId: "raw-mdtodo-file" }) : null,
|
|
|
|
|
),
|
|
|
|
|
},
|
|
|
|
|
h(TaskTree, { tasks, selectedId: selectedTask?.id || "", onSelect: (id: string) => setState((prev: any) => ({ ...prev, selectedTaskId: id, command: null })) }),
|
|
|
|
|
),
|
|
|
|
|
h(Panel, {
|
|
|
|
|
title: selectedTask ? `${selectedTask.id} 详情` : "任务详情",
|
|
|
|
|
eyebrow: selectedTask ? statusLabel(selectedTask.status) : "Detail",
|
|
|
|
|
loading: state.saving,
|
|
|
|
|
className: "mdtodo-detail-panel",
|
|
|
|
|
actions: selectedTask ? h("div", { className: "panel-actions" },
|
|
|
|
|
h("button", { type: "button", className: "ghost-btn", onClick: () => void patchSelected({ status: "pending" }), disabled: state.saving }, "待处理"),
|
|
|
|
|
h("button", { type: "button", className: "ghost-btn", onClick: () => void patchSelected({ status: "in_progress" }), disabled: state.saving }, "进行中"),
|
|
|
|
|
h("button", { type: "button", className: "primary-btn", onClick: () => void patchSelected({ status: "completed" }), disabled: state.saving }, "完成"),
|
|
|
|
|
) : null,
|
|
|
|
|
},
|
|
|
|
|
!selectedTask ? h(EmptyState, { title: "未选中任务", text: "请选择一个任务。" }) :
|
|
|
|
|
h("div", { className: "mdtodo-editor" },
|
|
|
|
|
h("label", null, "标题", h("input", { value: draftTitle, onChange: (event: any) => setDraftTitle(event.target.value), "data-testid": "mdtodo-title-input" })),
|
|
|
|
|
h("label", null, "正文", h("textarea", { value: draftRaw, onChange: (event: any) => setDraftRaw(event.target.value), rows: 12, "data-testid": "mdtodo-raw-input" })),
|
|
|
|
|
h("div", { className: "docker-meta compact" },
|
|
|
|
|
h("span", null, `line ${Number(selectedTask.lineNumber ?? 0) + 1}`),
|
|
|
|
|
h("span", null, `depth ${selectedTask.depth ?? 0}`),
|
|
|
|
|
h("span", null, `links ${selectedTask.linkExists ?? 0}/${selectedTask.linkCount ?? 0}`),
|
|
|
|
|
),
|
|
|
|
|
h("div", { className: "inline-actions" },
|
|
|
|
|
h("button", { type: "button", className: "primary-btn", onClick: saveSelected, disabled: state.saving }, state.saving ? "保存中" : "保存"),
|
|
|
|
|
h("button", { type: "button", className: "ghost-btn", onClick: generateCommand, disabled: state.saving }, "生成执行命令"),
|
|
|
|
|
h("button", { type: "button", className: "danger-btn", onClick: deleteSelected, disabled: state.saving }, "删除"),
|
|
|
|
|
h(RawButton, { title: `MDTODO ${selectedTask.id}`, data: selectedTask, onOpen: onRaw, testId: "raw-mdtodo-task" }),
|
|
|
|
|
),
|
|
|
|
|
state.command ? h("div", { className: "mdtodo-command" },
|
|
|
|
|
h("span", null, state.command.prompt || "--"),
|
|
|
|
|
h("code", null, state.command.command || "--"),
|
|
|
|
|
) : null,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|