feat: add mdtodo v3s microservice

This commit is contained in:
Codex
2026-05-16 03:27:22 +00:00
parent 7b86eb4217
commit 502f5971b1
18 changed files with 1383 additions and 5 deletions
+110
View File
@@ -6490,3 +6490,113 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); }
grid-template-columns: 1fr;
}
}
.mdtodo-page {
display: grid;
gap: 12px;
}
.mdtodo-layout {
display: grid;
grid-template-columns: minmax(220px, 0.7fr) minmax(320px, 1.25fr) minmax(320px, 1fr);
gap: 12px;
align-items: start;
}
.mdtodo-file-list {
display: grid;
gap: 8px;
}
.mdtodo-file-item,
.mdtodo-task-row {
width: 100%;
min-width: 0;
border: 1px solid var(--line-soft);
background: rgba(0,0,0,0.16);
color: var(--text);
text-align: left;
}
.mdtodo-file-item {
display: grid;
gap: 4px;
padding: 10px;
}
.mdtodo-file-item strong,
.mdtodo-file-item span,
.mdtodo-file-item code {
min-width: 0;
overflow-wrap: anywhere;
}
.mdtodo-file-item span {
color: var(--muted);
}
.mdtodo-file-item.active,
.mdtodo-task-row.active {
border-color: rgba(215,161,58,0.75);
background: rgba(215,161,58,0.08);
}
.mdtodo-tree,
.mdtodo-tree ol {
display: grid;
gap: 6px;
margin: 0;
padding: 0;
list-style: none;
}
.mdtodo-tree ol {
margin-top: 6px;
}
.mdtodo-task-row {
display: grid;
grid-template-columns: auto auto minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
min-height: 34px;
padding: 7px 8px 7px calc(8px + (var(--task-depth, 0) * 16px));
}
.mdtodo-task-row code {
color: var(--accent);
white-space: nowrap;
}
.mdtodo-task-title {
min-width: 0;
overflow-wrap: anywhere;
}
.mdtodo-link-count {
color: var(--muted);
font-size: 11px;
}
.mdtodo-editor {
display: grid;
gap: 10px;
}
.mdtodo-editor label {
display: grid;
gap: 5px;
color: var(--muted);
font-size: 11px;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.mdtodo-editor textarea {
min-height: 260px;
resize: vertical;
}
.mdtodo-command {
display: grid;
gap: 6px;
padding: 10px;
border: 1px solid var(--line-soft);
background: rgba(0,0,0,0.22);
}
.mdtodo-command span {
color: var(--muted);
}
.mdtodo-command code {
white-space: normal;
overflow-wrap: anywhere;
}
@media (max-width: 1100px) {
.mdtodo-layout {
grid-template-columns: 1fr;
}
}
+3
View File
@@ -7,6 +7,7 @@ import { CodeQueuePage } from "./code-queue";
import { FileBrowserPage } from "./filebrowser";
import { FindJobPage } from "./findjob";
import { MetNonlinearPage } from "./met-nonlinear";
import { MdtodoPage } from "./mdtodo";
import { canonicalizeKnownRoute, createRouteRegistry, DEFAULT_ACTIVE_TABS, MODULES, pathForTarget, resolveRouteTarget } from "./navigation";
import { OaEventFlowPage } from "./oa-event-flow";
import { PipelinePage } from "./pipeline";
@@ -1650,6 +1651,7 @@ function MicroserviceCatalogPage({ microservices, onRaw, onNavigate }: AnyRecord
service.id === "oa-event-flow" ? h("button", { type: "button", className: "ghost-btn", onClick: () => onNavigate("apps", "oa-event-flow"), "data-testid": "open-oa-event-flow-button" }, "打开") : null,
service.id === "v3sctl-adapter" ? h("button", { type: "button", className: "ghost-btn", onClick: () => onNavigate("apps", "v3sctl"), "data-testid": "open-v3sctl-button" }, "打开") : null,
service.id === "code-queue" ? h("button", { type: "button", className: "ghost-btn", onClick: () => onNavigate("apps", "code-queue"), "data-testid": "open-code-queue-button" }, "打开") : null,
service.id === "mdtodo" ? h("button", { type: "button", className: "ghost-btn", onClick: () => onNavigate("apps", "mdtodo"), "data-testid": "open-mdtodo-button" }, "打开") : null,
service.id === "project-manager" ? h("button", { type: "button", className: "ghost-btn", onClick: () => onNavigate("apps", "project-manager"), "data-testid": "open-project-manager-button" }, "打开") : null,
h(RawButton, { title: `用户服务 ${service.id}`, data: service, onOpen: onRaw }),
),
@@ -2157,6 +2159,7 @@ function WorkArea({ activeModule, activeTab, data, session, refresh, onRaw, onNa
if (activeModule === "apps" && activeTab === "oa-event-flow") return h(OaEventFlowPage, { microservices: data.microservices, onRaw, apiBaseUrl: cfg.apiBaseUrl });
if (activeModule === "apps" && activeTab === "v3sctl") return h(V3sCtlPage, { microservices: data.microservices, onRaw, apiBaseUrl: cfg.apiBaseUrl, onNavigate });
if (activeModule === "apps" && activeTab === "code-queue") return h(CodeQueuePage, { microservices: data.microservices, onRaw, apiBaseUrl: cfg.apiBaseUrl, initialTasksData: initialCodeQueueOverview });
if (activeModule === "apps" && activeTab === "mdtodo") return h(MdtodoPage, { microservices: data.microservices, onRaw, apiBaseUrl: cfg.apiBaseUrl });
if (activeModule === "apps" && activeTab === "project-manager") return h(ProjectManagerPage, { microservices: data.microservices, onRaw, apiBaseUrl: cfg.apiBaseUrl });
if (activeModule === "config" && activeTab === "topology") return h(TopologyPage, { data });
if (activeModule === "config" && activeTab === "auth") return h(AuthPage, { session });
+327
View File
@@ -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,
),
),
),
);
}
@@ -72,6 +72,7 @@ export const MODULES: UniDeskModuleDefinition[] = [
{ id: "oa-event-flow", label: "OA Event Flow" },
{ id: "v3sctl", label: "V3S Control" },
{ id: "code-queue", label: "Code Queue" },
{ id: "mdtodo", label: "MDTODO" },
{ id: "project-manager", label: "Project Manager" },
] },
{ id: "config", label: "系统配置", code: "CFG", tabs: [