console: 一页式控制台排版 + 数据卡片
保持 Muse 风格(白底、细线、灰/浅蓝气泡),不再四栏切视图: 顶栏(连接/健康/配额/刷新 + 锚点导航)+ 6 张数据卡片 (健康、配额、任务、Workers、项目、项目数据,均取自已有 store 数据,未加载不以 0 充数)+ 所有模块按顺序铺在一页 (派发、任务列表、对话+详情、项目、环境、Workers、技能、 自动化、分诊、管理区、API Key)。图标栏与右侧栏删除, 内容分别移到顶栏与各自模块下;今天列表与任务列表重复, 不再单独保留;对话为空时不再重复渲染派发表单。 含自动选第一个项目修复(主要聊天不再空)与继续消息 历史,console 标记单测本地 53 项通过。
This commit is contained in:
1 parent
a13762cfbe
commit
8b455c6554
9 files changed
+263
-272
No files matched your search
@@ -1,18 +1,16 @@
|
||||
import { useConsole } from "../context";
|
||||
import TracePanel from "./detail/TracePanel";
|
||||
import ContinuePanel from "./detail/ContinuePanel";
|
||||
import DispatchSection from "./DispatchSection";
|
||||
|
||||
// Centre column for the task view: the conversation is the protagonist.
|
||||
// Dispatch is kept here as the way a new conversation starts; it is not
|
||||
// a stack of work cards. Trace timeline is above, continue input below.
|
||||
// Dashboard conversation section: trace timeline above, continue input
|
||||
// below. Dispatch lives in its own section above on the same page, so
|
||||
// it is NOT duplicated here when no task is selected — only the hint.
|
||||
export default function ConversationSection() {
|
||||
const ctx = useConsole();
|
||||
if (!ctx.currentTask) {
|
||||
return (
|
||||
<div className="conversation">
|
||||
<DispatchSection />
|
||||
<div className="muted">左侧点一条任务,对话就切到该任务;也可以先在上面派发一个新任务。</div>
|
||||
<div className="muted">在任务列表里点一条任务,对话和详情就切到该任务;也可以在上面派发一个新任务。</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useConsole } from "../context";
|
||||
import { workerAgeText } from "../api/client";
|
||||
|
||||
// Dashboard data cards: computed ONLY from data the store already
|
||||
// fetches (health, quota, tasks, workers, projects, envs, members,
|
||||
// skills/apps/MCP). No new API endpoint, no new API call. A card never
|
||||
// presents 0 as a real count when its dataset is not loaded or its
|
||||
// fetch failed: it shows 未加载 / 先选项目 / 加载失败 plus the
|
||||
// existing error string instead.
|
||||
function Card({ label, value, sub }: { label: string; value: string; sub: string }) {
|
||||
return (
|
||||
<div className="dash-card data-card">
|
||||
<div className="dash-card-label">{label}</div>
|
||||
<div className="dash-card-value">{value}</div>
|
||||
<div className="muted">{sub}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DataCards() {
|
||||
const ctx = useConsole();
|
||||
|
||||
const healthValue = ctx.healthText === "未加载" ? "未加载"
|
||||
: ctx.healthText.includes("worker_ok=true") ? "正常"
|
||||
: ctx.healthText.includes("worker_ok=false") ? "异常"
|
||||
: ctx.healthText.includes("加载失败") ? "加载失败" : "未知";
|
||||
|
||||
const quotaMatch = ctx.quotaText.match(/剩余 \$([0-9.]+)/);
|
||||
const quotaValue = ctx.quotaText.includes("加载失败") ? "加载失败"
|
||||
: ctx.quotaText.includes("未填 key") || ctx.quotaText === "未加载" ? "未加载"
|
||||
: quotaMatch ? "$" + quotaMatch[1] : "配额";
|
||||
|
||||
const statusCounts = (["running", "completed", "failed", "queued", "merged", "interrupted"] as const)
|
||||
.map((s) => ctx.tasks.filter((t) => t.status === s).length)
|
||||
.map((n, i) => (["running", "completed", "failed", "queued", "merged", "interrupted"] as const)[i] + " " + n)
|
||||
.join(" | ");
|
||||
const selectedProject = ctx.projects.find((p) => p.id === ctx.projectId);
|
||||
const tasksCard = ctx.taskError
|
||||
? { value: "加载失败", sub: ctx.taskError }
|
||||
: !ctx.projectId
|
||||
? { value: "先选项目", sub: ctx.projectMsg || "项目加载后自动选第一个" }
|
||||
: { value: String(ctx.tasks.length), sub: ctx.taskCountText + " | " + statusCounts + (selectedProject ? " | " + selectedProject.name : "") };
|
||||
|
||||
let workersCard: { value: string; sub: string };
|
||||
if (ctx.workersError) {
|
||||
workersCard = { value: "加载失败", sub: ctx.workersError };
|
||||
} else if (ctx.workers === null) {
|
||||
workersCard = { value: ctx.workersSummary || "未加载", sub: "完整 worker 清单仅全局管理员可见" };
|
||||
} else {
|
||||
const online = ctx.workers.filter((w) => {
|
||||
if (w.alive !== undefined && w.alive !== null) return !!w.alive;
|
||||
const age = workerAgeText(w).age;
|
||||
return age !== null && age <= 120;
|
||||
}).length;
|
||||
workersCard = { value: online + " 在线 / " + ctx.workers.length, sub: (ctx.workers.length - online) + " 离线" };
|
||||
}
|
||||
|
||||
const projectsCard = ctx.projectMsg.includes("加载失败")
|
||||
? { value: "加载失败", sub: ctx.projectMsg }
|
||||
: { value: ctx.projects.length + " 个", sub: selectedProject ? "当前项目:" + selectedProject.name : "未选项目(加载后自动选第一个)" };
|
||||
|
||||
const projectErrors = [ctx.envMsg, ctx.memberError, ctx.skillError].filter((s) => s && s.includes("失败"));
|
||||
const projectDataCard = !ctx.projectId
|
||||
? { value: "先选项目", sub: "选项目后显示环境 / 成员 / 技能计数" }
|
||||
: projectErrors.length
|
||||
? { value: "部分加载失败", sub: projectErrors.join(" | ") }
|
||||
: { value: ctx.envs.length + " 个环境", sub: ctx.members.length + " 成员 | " + ctx.skills.length + " 技能 | " + ctx.apps.length + " 应用 | " + ctx.mcpServers.length + " MCP" };
|
||||
|
||||
return (
|
||||
<section className="dash-cards" aria-label="数据卡片">
|
||||
<Card label="健康" value={healthValue} sub={ctx.healthText} />
|
||||
<Card label="配额" value={quotaValue} sub={ctx.quotaText} />
|
||||
<Card label="任务" value={tasksCard.value} sub={tasksCard.sub} />
|
||||
<Card label="Workers" value={workersCard.value} sub={workersCard.sub} />
|
||||
<Card label="项目" value={projectsCard.value} sub={projectsCard.sub} />
|
||||
<Card label="项目数据" value={projectDataCard.value} sub={projectDataCard.sub} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
import { useConsole } from "../context";
|
||||
import { ModuleIcon, type View } from "../components/IconRail";
|
||||
import { shortId } from "../api/client";
|
||||
import TaskDetailPanel from "./TaskDetailPanel";
|
||||
import MembersPanel from "./MembersPanel";
|
||||
import EnvContextPanel from "./EnvContextPanel";
|
||||
import SkillsToolsPanel from "./SkillsToolsPanel";
|
||||
import HealthPanel from "./HealthPanel";
|
||||
import AutomationsSection from "./AutomationsSection";
|
||||
import TriageSection from "./TriageSection";
|
||||
|
||||
// Right panel, modelled on the Muse screenshot: profile header with a
|
||||
// real connection status (API key / health, never a fake user), an icon
|
||||
// row that switches modules, a 今天 list of recent tasks, and below it
|
||||
// the selected task's detail (or the current module's context) as a
|
||||
// collapsible section. Nothing that was in the old right column is
|
||||
// dropped: the detail/context panels are the same components, only
|
||||
// rendered inside <details> under the today list.
|
||||
export default function RightPanel({ view, onSelect, onClose }: {
|
||||
view: View; onSelect: (v: View) => void; onClose: () => void;
|
||||
}) {
|
||||
const ctx = useConsole();
|
||||
const connected = ctx.keySaved;
|
||||
const today = ctx.tasks.slice().reverse().slice(0, 10);
|
||||
const context = (() => {
|
||||
switch (view) {
|
||||
case "projects": return <MembersPanel />;
|
||||
case "env": return <EnvContextPanel />;
|
||||
case "skills": return <SkillsToolsPanel />;
|
||||
case "workers": case "admin": case "key": return <HealthPanel />;
|
||||
case "automations": return <TriageSection />;
|
||||
case "triage": return <AutomationsSection />;
|
||||
default: return <TaskDetailPanel />;
|
||||
}
|
||||
})();
|
||||
const iconRow: Array<[View, string]> = [["task", "任务"], ["admin", "管理区"], ["automations", "自动化"], ["key", "API Key"]];
|
||||
return (
|
||||
<div>
|
||||
<div className="right-profile">
|
||||
<button type="button" className="icon-btn" aria-label="关闭右侧栏" onClick={onClose}>×</button>
|
||||
<div className="right-avatar" aria-hidden="true">C</div>
|
||||
<div className="right-name">Codex 命令台</div>
|
||||
<div className={connected ? "right-status" : "right-status off"}>
|
||||
{connected ? "● 已连接" : "○ 未连接(先在 API Key 里保存 key)"}
|
||||
</div>
|
||||
<div className="muted">{ctx.healthText}</div>
|
||||
<div className="muted">{ctx.quotaText}</div>
|
||||
<div className="muted">自动刷新 · 每 10 秒 · 已刷新 {ctx.refreshStamp}</div>
|
||||
</div>
|
||||
<div className="right-icon-row" aria-label="快捷方式">
|
||||
{iconRow.map(([v, label]) => (
|
||||
<button key={v} type="button" title={label} aria-label={label} className={view === v ? "active" : ""} onClick={() => onSelect(v)}>
|
||||
<ModuleIcon view={v} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div>
|
||||
<div className="list-section-head"><span>今天</span></div>
|
||||
{today.length === 0 ? (
|
||||
<div className="muted">今天暂无任务</div>
|
||||
) : (
|
||||
<ul className="today-list">
|
||||
{today.map((t) => {
|
||||
const done = t.status === "completed" || t.status === "merged";
|
||||
const time = t.updated_at ? new Date(t.updated_at * 1000).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) : "";
|
||||
return (
|
||||
<li key={t.id} className="today-item" onClick={() => { ctx.showDetail(t.id); onSelect("task"); }}>
|
||||
<span className={"today-check" + (done ? " done" : "")}>{done ? "✓" : ""}</span>
|
||||
<span className="today-item-main">
|
||||
<span className="today-item-title">{String(t.prompt || shortId(t.id)).slice(0, 60) || shortId(t.id)}</span>
|
||||
<span className="today-item-sub">{shortId(t.id)} · <span className={"st-" + t.status}>{t.status}</span> · {t.executor || ""}</span>
|
||||
</span>
|
||||
<span className="today-item-time">{time}</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
<details className="right-detail" open>
|
||||
<summary>{view === "task" ? "任务详情" : "模块详情"}</summary>
|
||||
{context}
|
||||
</details>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -19,6 +19,12 @@ export default function TaskListSection({ onNew }: { onNew: () => void }) {
|
||||
</svg>
|
||||
<input type="text" placeholder="搜索" aria-label="搜索任务" value={ctx.searchText} onChange={(e) => ctx.setSearchText(e.target.value)} />
|
||||
</div>
|
||||
<div className="bar">
|
||||
<select aria-label="项目" value={ctx.projectId} onChange={(e) => ctx.selectProject(e.target.value)}>
|
||||
{ctx.projects.length === 0 ? <option value="">(先加载项目)</option> : null}
|
||||
{ctx.projects.map((p) => <option key={p.id} value={p.id}>{p.name} ({shortId(p.id)})</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="list-section-head">
|
||||
<span>主要聊天</span>
|
||||
<button type="button" aria-label="派发新任务" title="派发新任务" onClick={onNew}>+</button>
|
||||
|
||||
Reference in new issue
Block a user