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
+108
-50
@@ -1,72 +1,130 @@
|
||||
import { useState } from "react";
|
||||
import { ConsoleContext } from "./context";
|
||||
import { ConsoleContext, useConsole } from "./context";
|
||||
import { useConsoleStore } from "./hooks/useConsoleStore";
|
||||
import { useAutoRefresh, useIntervalMs } from "./hooks/useAutoRefresh";
|
||||
import IconRail, { type View } from "./components/IconRail";
|
||||
import DataCards from "./sections/DataCards";
|
||||
import DispatchSection from "./sections/DispatchSection";
|
||||
import TaskListSection from "./sections/TaskListSection";
|
||||
import ConversationSection from "./sections/ConversationSection";
|
||||
import TaskDetailPanel from "./sections/TaskDetailPanel";
|
||||
import ApiKeySection from "./sections/ApiKeySection";
|
||||
import AdminSection from "./sections/AdminSection";
|
||||
import ProjectsSection from "./sections/ProjectsSection";
|
||||
import MembersPanel from "./sections/MembersPanel";
|
||||
import EnvSection from "./sections/EnvSection";
|
||||
import EnvContextPanel from "./sections/EnvContextPanel";
|
||||
import WorkersSection from "./sections/WorkersSection";
|
||||
import HealthPanel from "./sections/HealthPanel";
|
||||
import SkillsSection from "./sections/SkillsSection";
|
||||
import SkillsToolsPanel from "./sections/SkillsToolsPanel";
|
||||
import AutomationsSection from "./sections/AutomationsSection";
|
||||
import TriageSection from "./sections/TriageSection";
|
||||
import TaskListSection from "./sections/TaskListSection";
|
||||
import ConversationSection from "./sections/ConversationSection";
|
||||
import RightPanel from "./sections/RightPanel";
|
||||
|
||||
// Layout only, Muse web/desktop style: no big header bar.
|
||||
// Icon rail (56px) + left list (260px) + centre conversation (elastic,
|
||||
// content centred) + right panel (300px, profile/today/detail). The
|
||||
// centre top bar is minimal: a back arrow (back to the task view) on
|
||||
// the left, and, only when the right panel is closed, a button to
|
||||
// reopen it — the right panel's own X closes it. Selecting a task in
|
||||
// the left list switches the centre conversation and the right detail
|
||||
// to that task together; selecting a module switches the centre to
|
||||
// that module and the right detail to its context.
|
||||
// Layout only: ONE scrollable console page, Muse web/desktop tokens
|
||||
// (white, hairlines, grey/blue bubbles) but console ordering — no
|
||||
// icon-rail column, no left list column, no right panel column, no
|
||||
// view switching. Everything is on the page: a slim sticky top bar
|
||||
// (name + real connection status + health + quota + refresh stamp +
|
||||
// anchor nav), a data-cards row, then every module as a section in
|
||||
// order. The old right panel's content moved here: its profile/status
|
||||
// line is the top bar, its 今天 list is redundant with the full task
|
||||
// list section, and its detail/context panels sit under their own
|
||||
// sections below (detail under 对话, members under 项目, and so on).
|
||||
// The old icon rail's module switching is the anchor nav.
|
||||
const NAV: Array<[string, string]> = [
|
||||
["#sec-dispatch", "派发"], ["#sec-tasks", "任务"], ["#sec-conv", "对话"],
|
||||
["#sec-projects", "项目"], ["#sec-env", "环境"], ["#sec-workers", "Workers"],
|
||||
["#sec-skills", "技能"], ["#sec-automations", "自动化"], ["#sec-triage", "分诊"],
|
||||
["#sec-admin", "管理区"], ["#sec-key", "API Key"],
|
||||
];
|
||||
|
||||
function TopBar() {
|
||||
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" aria-label="页面导航">
|
||||
{NAV.map(([href, label]) => <a key={href} href={href}>{label}</a>)}
|
||||
</nav>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
// 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();
|
||||
const [view, setView] = useState<View>("task");
|
||||
const [rightOpen, setRightOpen] = useState(true);
|
||||
useAutoRefresh((selectFocused) => store.refreshAll(selectFocused), 10000);
|
||||
useIntervalMs(() => store.refreshSlow(), 60000);
|
||||
|
||||
const centre = (() => {
|
||||
switch (view) {
|
||||
case "projects": return <ProjectsSection />;
|
||||
case "env": return <EnvSection />;
|
||||
case "workers": return <WorkersSection />;
|
||||
case "skills": return <SkillsSection />;
|
||||
case "automations": return <AutomationsSection />;
|
||||
case "triage": return <TriageSection />;
|
||||
case "admin": return <AdminSection />;
|
||||
case "key": return <ApiKeySection />;
|
||||
default: return <ConversationSection />;
|
||||
}
|
||||
})();
|
||||
const scrollToDispatch = () => {
|
||||
document.getElementById("sec-dispatch")?.scrollIntoView({ behavior: "smooth" });
|
||||
};
|
||||
|
||||
return (
|
||||
<ConsoleContext.Provider value={store}>
|
||||
<div className={"muse-app" + (rightOpen ? "" : " no-right")}>
|
||||
<IconRail view={view} onSelect={setView} />
|
||||
<aside className="list-col">
|
||||
<TaskListSection onNew={() => setView("task")} />
|
||||
</aside>
|
||||
<main className="center-col">
|
||||
<div className="center-topbar">
|
||||
<button type="button" className="icon-btn" aria-label="返回任务" title="返回任务" onClick={() => setView("task")}>←</button>
|
||||
{rightOpen ? <span className="muted">Codex 命令台</span> : (
|
||||
<button type="button" onClick={() => setRightOpen(true)}>显示右侧栏</button>
|
||||
)}
|
||||
<TopBar />
|
||||
<main className="dash-page">
|
||||
<DataCards />
|
||||
<section id="sec-dispatch" className="dash-section">
|
||||
<DispatchSection />
|
||||
</section>
|
||||
<section id="sec-tasks" className="dash-section card">
|
||||
<h2>任务列表</h2>
|
||||
<TaskListSection onNew={scrollToDispatch} />
|
||||
</section>
|
||||
<section id="sec-conv" className="dash-section">
|
||||
<div className="card">
|
||||
<h2>对话</h2>
|
||||
<ConversationSection />
|
||||
</div>
|
||||
<div className="center-inner">{centre}</div>
|
||||
</main>
|
||||
{rightOpen ? (
|
||||
<aside className="right-col">
|
||||
<RightPanel view={view} onSelect={setView} onClose={() => setRightOpen(false)} />
|
||||
</aside>
|
||||
) : null}
|
||||
</div>
|
||||
<TaskDetailPanel />
|
||||
</section>
|
||||
<section id="sec-projects" className="dash-section">
|
||||
<ProjectsSection />
|
||||
<MembersPanel />
|
||||
</section>
|
||||
<section id="sec-env" className="dash-section">
|
||||
<EnvSection />
|
||||
<EnvContextPanel />
|
||||
</section>
|
||||
<section id="sec-workers" className="dash-section">
|
||||
<WorkersSection />
|
||||
<HealthPanel />
|
||||
</section>
|
||||
<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>
|
||||
<section id="sec-admin" className="dash-section">
|
||||
<AdminSection />
|
||||
</section>
|
||||
<section id="sec-key" className="dash-section">
|
||||
<ApiKeySection />
|
||||
</section>
|
||||
</main>
|
||||
</ConsoleContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
// Far-left icon rail from the Muse screenshot: one simple inline-SVG
|
||||
// icon per module, no emoji, no external image. This replaces LeftNav
|
||||
// as the module switcher; the left list panel below it is the task list.
|
||||
export type View = "task" | "projects" | "env" | "workers" | "skills" | "automations" | "triage" | "admin" | "key";
|
||||
|
||||
function Svg({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
{children}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ModuleIcon({ view }: { view: View }) {
|
||||
switch (view) {
|
||||
case "task": return <Svg><path d="M21 11.5a8.5 8.5 0 0 1-12.4 7.5L3 21l2-5.6A8.5 8.5 0 1 1 21 11.5Z" /></Svg>;
|
||||
case "projects": return <Svg><path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8Z" /><path d="M14 3v5h5" /><path d="M9 13h6M9 17h6" /></Svg>;
|
||||
case "env": return <Svg><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z" /></Svg>;
|
||||
case "workers": return <Svg><rect x="3" y="3" width="7" height="7" rx="1.5" /><rect x="14" y="3" width="7" height="7" rx="1.5" /><rect x="3" y="14" width="7" height="7" rx="1.5" /><rect x="14" y="14" width="7" height="7" rx="1.5" /></Svg>;
|
||||
case "skills": return <Svg><path d="M9 18h6M10 21h4" /><path d="M12 3a6 6 0 0 0-3.5 10.8c.8.6 1.5 1.4 1.5 2.2h4c0-.8.7-1.6 1.5-2.2A6 6 0 0 0 12 3Z" /></Svg>;
|
||||
case "automations": return <Svg><circle cx="12" cy="12" r="9" /><path d="M12 7v5l3 2" /></Svg>;
|
||||
case "triage": return <Svg><circle cx="12" cy="12" r="9" /><path d="M8 12.5l2.5 2.5L16 9.5" /></Svg>;
|
||||
case "admin": return <Svg><path d="M12 3l7 3v5c0 5-3.5 8-7 9-3.5-1-7-4-7-9V6Z" /><path d="M9 12l2 2 4-4" /></Svg>;
|
||||
case "key": return <Svg><circle cx="8" cy="15" r="4" /><path d="M11.5 11.5L20 3" /><path d="M17 6l2 2M20 3l-1 3" /></Svg>;
|
||||
default: return <Svg><circle cx="12" cy="12" r="9" /></Svg>;
|
||||
}
|
||||
}
|
||||
|
||||
const ITEMS: Array<[View, string]> = [
|
||||
["task", "任务"], ["projects", "项目"], ["env", "环境"], ["workers", "Workers"],
|
||||
["skills", "技能与 MCP"], ["automations", "自动化"], ["triage", "分诊"],
|
||||
["admin", "管理区"], ["key", "API Key"],
|
||||
];
|
||||
|
||||
export default function IconRail({ view, onSelect }: { view: View; onSelect: (v: View) => void }) {
|
||||
return (
|
||||
<nav className="icon-rail" aria-label="模块快捷方式">
|
||||
{ITEMS.map(([v, label]) => (
|
||||
<button key={v} type="button" title={label} aria-label={label} className={view === v ? "active" : ""} onClick={() => onSelect(v)}>
|
||||
<ModuleIcon view={v} />
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -35,6 +35,18 @@ export function useConsoleStore(): ConsoleCtx {
|
||||
void tools.refreshTriage(id);
|
||||
}, [catalog, taskList, tools]);
|
||||
|
||||
// Fix: the old single page's project <select> defaulted to its first
|
||||
// option once loaded, so tasks loaded without a click. The React
|
||||
// version is a controlled select with projectId "" on every reload,
|
||||
// so without this the task list stayed on "先选项目" forever and
|
||||
// auto-refresh never fixed it. Auto-select the first project only
|
||||
// while nothing is selected; an explicit user choice is never
|
||||
// overridden.
|
||||
const firstProjectId = catalog.projects[0]?.id;
|
||||
useEffect(() => {
|
||||
if (!catalog.projectId && firstProjectId) selectProject(firstProjectId);
|
||||
}, [catalog.projectId, firstProjectId, selectProject]);
|
||||
|
||||
const saveKeyInput = useCallback((v: string) => {
|
||||
catalog.saveKeyInput(v);
|
||||
if (!v.trim()) return;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -37,55 +37,28 @@ button.danger:hover{background:#fee4e2}
|
||||
.icon-btn{width:32px;height:32px;padding:0;display:grid;place-items:center;border:1px solid var(--line);border-radius:50%;background:#fff;color:var(--text);font-size:16px;line-height:1}
|
||||
.icon-btn:hover{background:#f9fafb}
|
||||
|
||||
/* App shell: icon rail + list + conversation + right panel, full viewport.
|
||||
Columns are separated by hairlines, not gaps or cards. */
|
||||
.muse-app{display:grid;grid-template-columns:56px 260px minmax(0,1fr) 300px;height:100vh;background:#fff}
|
||||
.muse-app.no-right{grid-template-columns:56px 260px minmax(0,1fr)}
|
||||
.icon-rail{border-right:1px solid var(--line);display:flex;flex-direction:column;align-items:center;gap:4px;padding:12px 0;overflow-y:auto}
|
||||
.icon-rail button{width:40px;height:40px;border:0;border-radius:12px;background:transparent;display:grid;place-items:center;color:var(--text);padding:0;flex:none}
|
||||
.icon-rail button:hover{background:#f2f2f2}
|
||||
.icon-rail button.active{background:var(--accent-soft);color:var(--accent)}
|
||||
.icon-rail svg{width:22px;height:22px}
|
||||
.list-col{border-right:1px solid var(--line);overflow-y:auto;padding:16px 12px;min-width:0}
|
||||
.search-box{display:flex;align-items:center;gap:8px;border:1px solid var(--line);border-radius:999px;padding:8px 12px;background:#fff}
|
||||
.search-box svg{flex:none;color:var(--muted)}
|
||||
.search-box input{border:0;padding:0;flex:1;min-width:0;font-size:14px;background:transparent}
|
||||
.search-box input:focus{box-shadow:none;outline:none}
|
||||
.list-section-head{display:flex;align-items:center;justify-content:space-between;padding:16px 4px 8px;font-weight:600;font-size:14px}
|
||||
.list-section-head button{width:28px;height:28px;padding:0;display:grid;place-items:center;border-radius:50%}
|
||||
.muse-list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:2px}
|
||||
.muse-item{padding:10px 12px;border-radius:12px;cursor:pointer;overflow:hidden}
|
||||
.muse-item:hover{background:#f9fafb}
|
||||
.muse-item.selected{background:#f2f2f2}
|
||||
.muse-item-title{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-size:14px}
|
||||
.muse-item-sub{display:flex;gap:6px;align-items:center;color:var(--muted);font-size:12px;margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.task-dot{width:8px;height:8px;border-radius:50%;background:currentColor;flex:none}
|
||||
.center-col{min-width:0;display:flex;flex-direction:column;overflow-y:auto;background:#fff}
|
||||
.center-topbar{position:sticky;top:0;z-index:5;display:flex;align-items:center;justify-content:space-between;padding:12px 16px;background:#fff;border-bottom:1px solid var(--line)}
|
||||
.center-inner{max-width:760px;margin:0 auto;width:100%;padding:24px 20px 16px;display:flex;flex-direction:column;gap:20px;flex:1}
|
||||
.right-col{border-left:1px solid var(--line);overflow-y:auto;padding:20px 16px;display:flex;flex-direction:column;gap:20px;min-width:0}
|
||||
.right-profile{text-align:center;position:relative}
|
||||
.right-profile .icon-btn{position:absolute;top:0;right:0}
|
||||
.right-avatar{width:72px;height:72px;border-radius:50%;background:var(--accent-soft);color:var(--accent);display:grid;place-items:center;font-size:28px;font-weight:700;margin:0 auto}
|
||||
.right-name{font-weight:700;font-size:17px;margin-top:10px}
|
||||
.right-status{color:var(--green);font-size:13px;font-weight:600}
|
||||
.right-status.off{color:var(--muted)}
|
||||
.right-icon-row{display:flex;gap:4px;justify-content:space-between;border-top:1px solid var(--line);border-bottom:1px solid var(--line);padding:10px 4px}
|
||||
.right-icon-row button{width:36px;height:36px;border:0;border-radius:12px;background:transparent;display:grid;place-items:center;color:var(--text);padding:0}
|
||||
.right-icon-row button:hover{background:#f2f2f2}
|
||||
.right-icon-row button.active{background:var(--accent-soft);color:var(--accent)}
|
||||
.right-icon-row svg{width:20px;height:20px}
|
||||
.today-list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:14px}
|
||||
.today-item{display:flex;gap:10px;cursor:pointer;align-items:flex-start}
|
||||
.today-check{width:22px;height:22px;border-radius:50%;border:1.5px solid var(--line-strong);display:grid;place-items:center;flex:none;font-size:13px;color:var(--muted)}
|
||||
.today-check.done{border-color:var(--green);background:var(--green);color:#fff}
|
||||
.today-item-main{flex:1;min-width:0;display:block}
|
||||
.today-item-title{display:block;font-weight:600;font-size:14px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.today-item-sub{display:block;color:var(--muted);font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.today-item-time{color:var(--muted);font-size:12px;flex:none;white-space:nowrap}
|
||||
.right-detail summary{font-weight:600;font-size:14px;cursor:pointer;padding:8px 0}
|
||||
.right-detail .card{border:0;padding:0;box-shadow:none}
|
||||
.right-detail .card h2{font-size:16px}
|
||||
/* Dashboard shell: ONE scrollable console page, same Muse tokens.
|
||||
Slim sticky top bar (name + status + health + quota + refresh
|
||||
stamp + anchor nav), a data-cards row, then every module as a
|
||||
section. No icon-rail column, no left list column, no right
|
||||
panel column — the old shell classes are gone with them. */
|
||||
.dash-topbar{position:sticky;top:0;z-index:10;background:#fff;border-bottom:1px solid var(--line);padding:10px 20px 8px}
|
||||
.dash-topbar-row{display:flex;gap:14px;align-items:center;flex-wrap:wrap}
|
||||
.dash-brand{font-weight:700;font-size:17px}
|
||||
.dash-status{font-size:13px;font-weight:600;color:var(--muted)}
|
||||
.dash-status.ok{color:var(--green)}
|
||||
.dash-stamp{margin-left:auto}
|
||||
.dash-nav{display:flex;gap:4px;flex-wrap:wrap;margin-top:8px}
|
||||
.dash-nav a{color:var(--text);text-decoration:none;font-size:13px;font-weight:600;padding:6px 12px;border-radius:999px}
|
||||
.dash-nav a:hover{background:#f2f2f2}
|
||||
.dash-page{max-width:1180px;margin:0 auto;padding:20px;display:flex;flex-direction:column;gap:20px}
|
||||
.dash-section{scroll-margin-top:130px;display:flex;flex-direction:column;gap:20px}
|
||||
.dash-section.card{padding:20px}
|
||||
.dash-cards{display:grid;grid-template-columns:repeat(3,1fr);gap:12px}
|
||||
.dash-card{background:#fff;border:1px solid var(--line);border-radius:var(--radius);padding:16px 18px;min-width:0}
|
||||
.dash-card-label{color:var(--muted);font-size:13px;font-weight:600}
|
||||
.dash-card-value{font-size:28px;font-weight:700;line-height:1.3;overflow-wrap:anywhere}
|
||||
|
||||
|
||||
/* Cards: flat white sections with a hairline border, no shadow. */
|
||||
.card{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);box-shadow:none;padding:20px}
|
||||
@@ -158,19 +131,17 @@ pre{background:#f9fafb;color:var(--text);border:1px solid var(--line);border-rad
|
||||
.chat-send{width:36px;height:36px;border-radius:50%;background:var(--bubble-user);border:0;color:var(--accent-dark);padding:0;display:grid;place-items:center;font-size:18px;line-height:1;flex:none}
|
||||
.chat-send:disabled{opacity:.45;background:#f2f2f2;color:var(--muted)}
|
||||
.conv-divider{border-top:1px solid var(--line);margin:4px 0}
|
||||
@media (max-width:1240px){
|
||||
.muse-app{grid-template-columns:56px 260px minmax(0,1fr)}
|
||||
.muse-app.no-right{grid-template-columns:56px 260px minmax(0,1fr)}
|
||||
.right-col{grid-column:1 / -1;border-left:0;border-top:1px solid var(--line)}
|
||||
}
|
||||
@media (max-width:900px){
|
||||
.muse-app,.muse-app.no-right{grid-template-columns:1fr}
|
||||
.icon-rail{display:none}
|
||||
.center-inner{padding:16px 14px}
|
||||
.dash-cards{grid-template-columns:repeat(2,1fr)}
|
||||
.dash-hide-sm{display:none}
|
||||
.dash-page{padding:14px}
|
||||
.dash-section.card{padding:16px}
|
||||
.card{padding:16px}
|
||||
th:nth-child(2),td:nth-child(2),th:nth-child(3),td:nth-child(3),th:nth-child(7),td:nth-child(7){display:none}
|
||||
}
|
||||
@media (max-width:560px){
|
||||
.dash-cards{grid-template-columns:1fr}
|
||||
.dash-nav{flex-wrap:nowrap;overflow-x:auto}
|
||||
.card{padding:16px}
|
||||
th:nth-child(6),td:nth-child(6),th:nth-child(9),td:nth-child(9){display:none}
|
||||
.bar > input[type="text"],.bar > input[type="password"],.bar > select{flex:1 1 100%}
|
||||
|
||||
Reference in new issue
Block a user