console: 重排版为侧边导航 + 三栏工作台 + 任务详情内页签

旧排版(顶栏页签 + 数据卡片置顶 + 长页堆叠 + 详情卡片堆在对话下)全部去掉,功能不变:
- 左侧栏:导航按任务/管理分组,底部紧凑概览卡片始终可见;整页不再单页长滚动,各 pane 各自滚动
- 任务工作台:左任务列表轨(搜索/筛选/分页),中派发(可收起)+ 单列对话,右任务详情检查器
- 详情检查器:概览/协作/评审与审批/线程四个内页签,一次只显示一组
- 其余功能各成独立页面:项目与成员、环境与 Workers、技能与自动化、系统与 Key
- 视图同步到 location.hash(#view-tasks 等)

构建:npm run build(tsc + vite singlefile)通过,产物 332130 字节;本地全量 cloud 1275 项全过(skipped 4)。
This commit is contained in:
host committed 2026-10-30 03:22:02 +08:00
1 parent f0aba700e0
commit 44b080971e
4 files changed
+187 -148

No files matched your search

+86 -113
View File
@@ -20,156 +20,129 @@ import SkillsToolsPanel from "./sections/SkillsToolsPanel";
import AutomationsSection from "./sections/AutomationsSection";
import TriageSection from "./sections/TriageSection";
// Layout: GitHub Primer style, data cards on top, then TAB pages
// instead of one endless page. The old layout stacked 11 sections
// (dispatch, tasks, conversation, projects, env, workers, skills,
// automations, triage, admin, API Key, plus detail panels) on one
// page; tabbing is what actually cuts the visible length, while
// list pagination (see useTaskList) cuts the data fetched per page.
// Tabs:
// 工作台:派发 + 任务列表(分页)+ 对话 + 任务详情
// 项目:项目 + 成员
// 环境与 Workers:环境 + 环境上下文 + Workers + 健康
// 技能与自动化:技能 + 技能工具 + 自动化 + 分诊
// 管理与 Key:管理区 + API Key
// Selected task survives tab switches: the store (useConsoleStore)
// lives in App, above the tabs, so currentTask / detail / chat
// state is not unmounted with a tab — switching back shows the same
// task, and the 10 s refresh re-syncs its detail lists. The tab is
// synced to location.hash (#tab-workbench etc.) so a reload or a
// shared link lands on the same tab; unknown hashes fall back to
// the workbench.
type TabId = "workbench" | "projects" | "env" | "skills" | "admin";
const TABS: Array<[TabId, string]> = [
["workbench", "工作台"], ["projects", "项目"], ["env", "环境与 Workers"],
["skills", "技能与自动化"], ["admin", "管理与 Key"],
// Redesign (2026-10-30): the old layout is gone — no top tab bar, no
// data-cards row on top of a long stacked page, no detail stack
// hanging under the conversation. The new shell is an IDE-style
// workspace that never scrolls as one page:
// 左侧栏:导航(按工作流分组)+ 数据卡片(紧凑版,始终可见)
// 任务工作台:左 = 任务列表轨(搜索/筛选/分页),中 = 派发 + 对话,
// 右 = 任务详情检查器(概览/协作/评审与审批/线程四个内页签,
// 见 TaskDetailPanel)
// 其余功能各成独立页面(项目 / 资源 / 自动化 / 系统),从左侧栏
// 切入,页面内只放该组功能,不再把 11 个模块堆在同一页。
// Selected task survives view switches: the store lives in App,
// above the views, so currentTask / detail / chat state is not
// unmounted with a view. The view is synced to location.hash
// (#view-tasks etc.) so a reload or a shared link lands on the
// same view; unknown hashes fall back to the task workspace.
type ViewId = "tasks" | "projects" | "resources" | "automation" | "admin";
const VIEWS: Array<[ViewId, string, string]> = [
["tasks", "任务工作台", "任务"],
["projects", "项目与成员", "管理"],
["resources", "环境与 Workers", "管理"],
["automation", "技能与自动化", "管理"],
["admin", "系统与 Key", "管理"],
];
function tabFromHash(): TabId {
const h = window.location.hash.replace("#tab-", "");
return (TABS.some(([id]) => id === h) ? h : "workbench") as TabId;
const VIEW_TITLES: Record<ViewId, string> = Object.fromEntries(
VIEWS.map(([id, title]) => [id, title]),
) as Record<ViewId, string>;
function viewFromHash(): ViewId {
const h = window.location.hash.replace("#view-", "");
return (VIEWS.some(([id]) => id === h) ? h : "tasks") as ViewId;
}
function TopBar({ tab, setTab }: { tab: TabId; setTab: (t: TabId) => void }) {
function SideNav({ view, setView }: { view: ViewId; setView: (v: ViewId) => void }) {
return (
<header className="dash-topbar">
<div className="dash-topbar-row">
<span className="dash-brand">Codex 命令台</span>
<TopStatus />
<span className="muted dash-hide-sm"><TopHealth /></span>
<span className="muted dash-hide-sm"><TopQuota /></span>
<span className="muted dash-stamp"><TopStamp /></span>
</div>
<nav className="dash-nav tab-nav" aria-label="页面签">
{TABS.map(([id, label]) => (
<button key={id} type="button" className={tab === id ? "active" : ""} onClick={() => setTab(id)}>{label}</button>
<aside className="shell-side">
<div className="shell-brand">Codex 命令台</div>
<nav className="shell-nav" aria-label="导航">
{["任务", "管理"].map((group) => (
<div key={group} className="shell-nav-group">
<div className="shell-nav-group-label">{group}</div>
{VIEWS.filter(([, , g]) => g === group).map(([id, label]) => (
<button key={id} type="button" className={view === id ? "active" : ""} onClick={() => setView(id)}>{label}</button>
))}
</div>
))}
</nav>
</header>
<div className="shell-stats">
<div className="shell-stats-label">概览</div>
<DataCards />
</div>
</aside>
);
}
// Tiny readers so the top bar re-renders from the store.
function TopStatus() {
const ctx = useConsole();
return <span className={ctx.keySaved ? "dash-status ok" : "dash-status off"}>{ctx.keySaved ? "● 已连接" : "○ 未连接"}</span>;
}
function TopHealth() {
return <>{useConsole().healthText}</>;
}
function TopQuota() {
return <>{useConsole().quotaText}</>;
}
function TopStamp() {
return <>自动刷新 · 每 10 秒 · 已刷新 {useConsole().refreshStamp}</>;
}
export default function App() {
const store = useConsoleStore();
useAutoRefresh((selectFocused) => store.refreshAll(selectFocused), 10000);
useIntervalMs(() => store.refreshSlow(), 60000);
const [tab, setTabState] = useState<TabId>(() => tabFromHash());
const setTab = (t: TabId) => {
setTabState(t);
try { window.history.replaceState(null, "", "#tab-" + t); } catch { /* noop */ }
const [view, setViewState] = useState<ViewId>(() => viewFromHash());
const [composerOpen, setComposerOpen] = useState(true);
const setView = (v: ViewId) => {
setViewState(v);
try { window.history.replaceState(null, "", "#view-" + v); } catch { /* noop */ }
};
useEffect(() => {
const onHash = () => setTabState(tabFromHash());
const onHash = () => setViewState(viewFromHash());
window.addEventListener("hashchange", onHash);
return () => window.removeEventListener("hashchange", onHash);
}, []);
const scrollToDispatch = () => {
setTab("workbench");
setView("tasks");
setComposerOpen(true);
setTimeout(() => document.getElementById("sec-dispatch")?.scrollIntoView({ behavior: "smooth" }), 0);
};
return (
<ConsoleContext.Provider value={store}>
<TopBar tab={tab} setTab={setTab} />
<main className="dash-page dash-page-tabs">
<DataCards />
{tab === "workbench" ? (
<div className="tab-content tab-workbench">
<div className="dash-col dash-col-left">
<section id="sec-tasks" className="dash-section card">
<div className="shell">
<SideNav view={view} setView={setView} />
<div className="shell-main">
<header className="shell-header">
<h1>{VIEW_TITLES[view]}</h1>
<TopStatus />
<span className="muted shell-hide-sm">{store.healthText}</span>
<span className="muted shell-hide-sm">{store.quotaText}</span>
<span className="muted shell-stamp">自动刷新 · 每 10 秒 · 已刷新 {store.refreshStamp}</span>
</header>
{view === "tasks" ? (
<div className="workspace">
<section className="workspace-rail" aria-label="任务列表">
<h2>任务列表</h2>
<TaskListSection onNew={scrollToDispatch} />
</section>
</div>
<div className="dash-col dash-col-mid">
<section id="sec-dispatch" className="dash-section">
<DispatchSection />
</section>
<section id="sec-conv" className="dash-section">
<div className="card">
<section className="workspace-center">
<div className="composer-toggle">
<button type="button" onClick={() => setComposerOpen(!composerOpen)}>
{composerOpen ? "收起新建任务" : "新建任务"}
</button>
</div>
{composerOpen ? <section id="sec-dispatch"><DispatchSection /></section> : null}
<section className="card workspace-conv">
<h2>对话</h2>
<ConversationSection />
</div>
</section>
</section>
<section className="workspace-inspector" aria-label="任务详情">
<TaskDetailPanel />
</section>
</div>
</div>
) : tab === "projects" ? (
<div className="tab-content">
<section id="sec-projects" className="dash-section">
<ProjectsSection />
<MembersPanel />
</section>
</div>
) : tab === "env" ? (
<div className="tab-content">
<section id="sec-env" className="dash-section">
<EnvSection />
<EnvContextPanel />
</section>
<section id="sec-workers" className="dash-section">
<WorkersSection />
<HealthPanel />
</section>
</div>
) : tab === "skills" ? (
<div className="tab-content">
<section id="sec-skills" className="dash-section">
<SkillsSection />
<SkillsToolsPanel />
</section>
<section id="sec-automations" className="dash-section">
<AutomationsSection />
</section>
<section id="sec-triage" className="dash-section">
<TriageSection />
</section>
</div>
) : (
<div className="tab-content">
<section id="sec-admin" className="dash-section">
<AdminSection />
</section>
<section id="sec-key" className="dash-section">
<ApiKeySection />
</section>
</div>
)}
</main>
) : (
<main className="page">
{view === "projects" ? (<><ProjectsSection /><MembersPanel /></>)
: view === "resources" ? (<><EnvSection /><EnvContextPanel /><WorkersSection /><HealthPanel /></>)
: view === "automation" ? (<><SkillsSection /><SkillsToolsPanel /><AutomationsSection /><TriageSection /></>)
: (<><AdminSection /><ApiKeySection /></>)}
</main>
)}
</div>
</div>
</ConsoleContext.Provider>
);
}
@@ -1,3 +1,4 @@
import { useState } from "react";
import { useConsole } from "../context";
import { Card } from "../components/ui";
import { shortId, fmt } from "../api/client";
@@ -7,19 +8,25 @@ import DetailActions from "./detail/DetailActions";
import ReviewPanel from "./detail/ReviewPanel";
import { CoordPanel, AgentsPanel, ApprovalPanel, PeerPanel, ThreadPanel } from "./detail/DetailLists";
// Right detail column for the task view, card-style layout.
// Everything structured lives here — never inside a chat bubble.
// Layout only: data, actions and sub-sections are unchanged,
// just grouped into cards instead of one <br>-separated block:
// 概览(状态 + 耗时/token/成本三张指标卡)
// 基本信息(键值网格)
// prompt(单独一张卡,长 prompt 不再挤在信息行末尾)
// 操作与结果
// 协调子任务 / 子代理链路 / 评审队列 / 运行时审批队列 /
// 给其他任务发消息 / 线程(各一张卡)
// Task inspector (redesign 2026-10-30): the old detail stack —
// 概览 + 基本信息 + prompt + 操作 + 协调 + 子代理 + 评审 +
// 审批 + 任务间消息 + 线程, all stacked under the conversation —
// is gone. The same panels now live in four inspector tabs so
// only one group is visible at a time:
// 概览:状态/耗时/token/成本 + 基本信息 + prompt + 操作与结果
// 协作:协调子任务 / 子代理链路 / 给其他任务发消息
// 评审与审批:评审队列 / 运行时审批队列
// 线程:线程管理
// Data, actions and sub-sections are unchanged; only grouping
// and visibility changed.
type DetailTab = "overview" | "collab" | "review" | "threads";
const DETAIL_TABS: Array<[DetailTab, string]> = [
["overview", "概览"], ["collab", "协作"], ["review", "评审与审批"], ["threads", "线程"],
];
export default function TaskDetailPanel() {
const ctx = useConsole();
const t = ctx.currentTask;
const [tab, setTab] = useState<DetailTab>("overview");
if (!t) return <Card id="nav-detail" title="任务详情"><div className="muted">点任务行查看详情</div></Card>;
const [dur, durTitle] = durationOf(t);
const [tok, tokTitle] = tokenOf(t);
@@ -30,6 +37,12 @@ export default function TaskDetailPanel() {
: t.verify_command ? "goal 模式(旧语义,验收命令)" : null;
return (
<div className="detail-stack" id="nav-detail">
<nav className="detail-tabs" aria-label="任务详情页签">
{DETAIL_TABS.map(([id, label]) => (
<button key={id} type="button" className={tab === id ? "active" : ""} onClick={() => setTab(id)}>{label}</button>
))}
</nav>
{tab === "overview" ? (<>
<Card title="任务详情">
<div style={{ display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap" }}>
<span className="issue-first" title={"任务首字:" + fc} aria-hidden="true">{fc}</span>
@@ -84,12 +97,16 @@ export default function TaskDetailPanel() {
<pre>{ctx.detailBody || "(暂无 result/error 输出)"}</pre>
{ctx.costBox ? <pre>{ctx.costBox}</pre> : null}
</Card>
</>) : tab === "collab" ? (<>
<Card title="协调子任务"><CoordPanel /></Card>
<Card title="子代理链路"><AgentsPanel /></Card>
<Card title="给其他任务发消息"><PeerPanel /></Card>
</>) : tab === "review" ? (<>
<Card title="评审队列"><ReviewPanel /></Card>
<Card title="运行时审批队列"><ApprovalPanel /></Card>
<Card title="给其他任务发消息"><PeerPanel /></Card>
</>) : (<>
<Card title="线程"><ThreadPanel /></Card>
</>)}
</div>
);
}
+49
View File
@@ -272,3 +272,52 @@ pre{background:var(--inset-bg);color:var(--text);border:1px solid var(--line);bo
.bar > input[type="text"],.bar > input[type="password"],.bar > select{flex:1 1 100%}
th,td{padding:8px 12px}
}
/* Redesign shell (2026-10-30): replaces the old top-tab + stacked-page
layout. Sidebar + workspace grid, each pane scrolls on its own;
the page itself never scrolls as one long column. */
.shell{display:grid;grid-template-columns:260px minmax(0,1fr);height:100vh;background:#fff}
.shell-side{background:#f6f8fa;border-right:1px solid var(--line);display:flex;flex-direction:column;min-height:0;overflow-y:auto}
.shell-brand{font-weight:600;font-size:16px;padding:16px 16px 12px}
.shell-nav{display:flex;flex-direction:column;gap:16px;padding:0 12px 16px}
.shell-nav-group-label{font-size:12px;font-weight:600;color:var(--muted);padding:0 8px 6px}
.shell-nav-group{display:flex;flex-direction:column;gap:2px}
.shell-nav button{text-align:left;border:0;box-shadow:none;background:transparent;padding:8px;border-radius:var(--radius);font-weight:500}
.shell-nav button:hover{background:#fff}
.shell-nav button.active{background:var(--text);color:#fff}
.shell-stats{margin-top:auto;border-top:1px solid var(--line);padding:16px 12px}
.shell-stats-label{font-size:12px;font-weight:600;color:var(--muted);padding:0 4px 8px}
.shell-stats .dash-cards{display:flex;flex-direction:column;gap:8px}
.shell-stats .dash-card{padding:12px}
.shell-stats .dash-card-value{font-size:18px}
.shell-main{display:flex;flex-direction:column;min-width:0;min-height:0}
.shell-header{display:flex;gap:14px;align-items:center;flex-wrap:wrap;padding:12px 20px;border-bottom:1px solid var(--line);background:#fff}
.shell-header h1{font-size:16px;margin:0}
.shell-header .dash-status{font-size:13px;font-weight:600}
.shell-header .dash-status.ok{color:var(--success)}
.shell-stamp{margin-left:auto}
.workspace{display:grid;grid-template-columns:300px minmax(0,1fr) 360px;flex:1;min-height:0}
.workspace-rail,.workspace-center,.workspace-inspector{overflow-y:auto;padding:16px;min-width:0}
.workspace-rail{border-right:1px solid var(--line);background:#fff}
.workspace-rail h2,.workspace-conv h2{font-size:14px;margin:0 0 12px;font-weight:600}
.workspace-rail .issue-list{max-height:none}
.workspace-center{background:#fff;display:flex;flex-direction:column;gap:16px}
.composer-toggle{display:flex}
.workspace-conv{flex:1}
.workspace-inspector{border-left:1px solid var(--line);background:var(--subtle-bg)}
.workspace-inspector .detail-stack{display:flex;flex-direction:column;gap:12px}
.detail-tabs{display:flex;gap:4px;flex-wrap:wrap}
.detail-tabs button{padding:6px 12px;font-size:13px;font-weight:600}
.detail-tabs button.active{background:var(--text);color:#fff;border-color:var(--text)}
.page{flex:1;overflow-y:auto;padding:20px;display:flex;flex-direction:column;gap:20px;max-width:1100px;width:100%}
.page > *{min-width:0}
@media (max-width:1100px){
.shell{grid-template-columns:1fr;height:auto;min-height:100vh}
.shell-side{flex-direction:row;flex-wrap:wrap;align-items:flex-start;border-right:0;border-bottom:1px solid var(--line);overflow:visible}
.shell-nav{flex-direction:row;flex-wrap:wrap}
.shell-nav-group{flex-direction:row;align-items:center}
.shell-stats{display:none}
.shell-hide-sm{display:none}
.workspace{grid-template-columns:1fr}
.workspace-rail,.workspace-inspector{border:0;border-bottom:1px solid var(--line)}
}