feat: polish xworkmate workspace entry
This commit is contained in:
parent
161350c608
commit
88825f62c3
@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useState, type KeyboardEvent } from "react";
|
||||
import Link from "next/link";
|
||||
import { ArrowRight, QrCode, X } from "lucide-react";
|
||||
|
||||
@ -31,78 +31,56 @@ export function HeroCard({
|
||||
guide,
|
||||
}: HeroCardProps) {
|
||||
const [showGuide, setShowGuide] = useState(false);
|
||||
const closeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const primaryLink = guide?.steps.find((step) =>
|
||||
step.link?.url.startsWith("/"),
|
||||
)?.link;
|
||||
const hasGuide = Boolean(guide);
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
if (closeTimeoutRef.current) {
|
||||
clearTimeout(closeTimeoutRef.current);
|
||||
closeTimeoutRef.current = null;
|
||||
function openGuide(): void {
|
||||
if (!hasGuide) {
|
||||
return;
|
||||
}
|
||||
setShowGuide(true);
|
||||
}
|
||||
|
||||
function handleCardKeyDown(event: KeyboardEvent<HTMLDivElement>): void {
|
||||
if (!hasGuide) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (guide) {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
setShowGuide(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
if (closeTimeoutRef.current) {
|
||||
clearTimeout(closeTimeoutRef.current);
|
||||
}
|
||||
|
||||
closeTimeoutRef.current = setTimeout(() => {
|
||||
setShowGuide(false);
|
||||
}, 300);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (closeTimeoutRef.current) {
|
||||
clearTimeout(closeTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const cardContent = (
|
||||
<div
|
||||
className={cn(
|
||||
"group relative flex items-start gap-4 rounded-2xl border border-surface-border bg-surface p-6 transition-all duration-300",
|
||||
primaryLink ? "cursor-pointer" : "",
|
||||
showGuide
|
||||
? "border-primary/50 shadow-lg"
|
||||
: "hover:border-primary/50 hover:bg-surface-hover",
|
||||
)}
|
||||
>
|
||||
<div className="mt-1 rounded-full border border-surface-border bg-surface-muted p-2 group-hover:border-primary/50 group-hover:text-primary">
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="flex w-full items-start justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<h3 className="font-semibold text-heading">{title}</h3>
|
||||
<p className="text-sm text-text-muted">{description}</p>
|
||||
</div>
|
||||
{primaryLink ? (
|
||||
<span className="inline-flex shrink-0 items-center gap-1 rounded-full border border-primary/20 bg-primary/10 px-3 py-1 text-xs font-semibold text-primary">
|
||||
点击进入
|
||||
<ArrowRight className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave}>
|
||||
{primaryLink ? (
|
||||
<Link href={primaryLink.url} className="block">
|
||||
{cardContent}
|
||||
</Link>
|
||||
) : (
|
||||
cardContent
|
||||
<div
|
||||
role={hasGuide ? "button" : undefined}
|
||||
tabIndex={hasGuide ? 0 : undefined}
|
||||
onClick={hasGuide ? openGuide : undefined}
|
||||
onKeyDown={handleCardKeyDown}
|
||||
className={cn(
|
||||
"group relative flex items-start gap-4 rounded-2xl border border-surface-border bg-surface p-6 transition-all duration-300",
|
||||
hasGuide
|
||||
? "cursor-pointer hover:border-primary/50 hover:bg-surface-hover"
|
||||
: "hover:border-primary/50 hover:bg-surface-hover",
|
||||
showGuide ? "border-primary/50 shadow-lg" : "",
|
||||
)}
|
||||
>
|
||||
<div className="mt-1 rounded-full border border-surface-border bg-surface-muted p-2 group-hover:border-primary/50 group-hover:text-primary">
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="flex w-full items-start justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<h3 className="font-semibold text-heading">{title}</h3>
|
||||
<p className="text-sm text-text-muted">{description}</p>
|
||||
</div>
|
||||
{hasGuide ? (
|
||||
<span className="inline-flex shrink-0 items-center gap-1 rounded-full border border-primary/20 bg-primary/10 px-3 py-1 text-xs font-semibold text-primary">
|
||||
点击查看向导
|
||||
<ArrowRight className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{guide ? (
|
||||
@ -111,8 +89,6 @@ export function HeroCard({
|
||||
"fixed top-0 right-0 z-[100] h-full w-[400px] transform border-l border-surface-border bg-surface shadow-2xl transition-transform duration-300 ease-in-out",
|
||||
showGuide ? "translate-x-0" : "translate-x-full",
|
||||
)}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
<div className="flex h-full flex-col overflow-y-auto p-8">
|
||||
<div className="mb-8 flex items-center justify-between">
|
||||
@ -124,6 +100,7 @@ export function HeroCard({
|
||||
{guide.title}
|
||||
</h4>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowGuide(false)}
|
||||
className="rounded-full p-2 text-text-muted transition-colors hover:bg-surface-muted hover:text-text"
|
||||
>
|
||||
@ -188,6 +165,7 @@ export function HeroCard({
|
||||
|
||||
<div className="mt-auto border-t border-surface-border pt-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowGuide(false)}
|
||||
className="w-full rounded-xl border border-surface-border py-3 text-sm font-medium text-text-muted transition-all hover:bg-surface-muted hover:text-text"
|
||||
>
|
||||
|
||||
@ -186,7 +186,7 @@ export default function Navbar() {
|
||||
openSource: isChinese ? "开源项目" : "Open source",
|
||||
about: isChinese ? "关于" : "About",
|
||||
moreServices: isChinese ? "更多服务" : "More services",
|
||||
chat: "XWorkmate",
|
||||
chat: isChinese ? "X助手" : "X Assistant",
|
||||
homepage: translations[language].homepage,
|
||||
overview: isChinese ? "概览" : "Overview",
|
||||
instances: isChinese ? "实例管理" : "Instances",
|
||||
|
||||
@ -89,6 +89,13 @@ interface StatusRowProps {
|
||||
ok?: boolean;
|
||||
}
|
||||
|
||||
interface OverviewMetric {
|
||||
label: string;
|
||||
value: string;
|
||||
caption: string;
|
||||
icon: LucideIcon;
|
||||
}
|
||||
|
||||
const INITIAL_TABS: Record<WorkspaceDestination, string> = {
|
||||
assistant: "workspace",
|
||||
tasks: "queue",
|
||||
@ -365,6 +372,40 @@ function StatusRow({ label, value, ok }: StatusRowProps) {
|
||||
);
|
||||
}
|
||||
|
||||
function OverviewMetrics({ items }: { items: OverviewMetric[] }) {
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
{items.map((item) => {
|
||||
const Icon = item.icon;
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={`${item.label}-${item.value}`}
|
||||
className="space-y-3 border-[color:var(--color-surface-border)] bg-[var(--color-surface)] p-5"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-2xl bg-[var(--color-primary-muted)] text-[var(--color-primary)]">
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-[var(--color-text-subtle)]">
|
||||
{item.label}
|
||||
</p>
|
||||
<p className="mt-1 text-lg font-semibold text-[var(--color-heading)]">
|
||||
{item.value}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-[var(--color-text-subtle)]">
|
||||
{item.caption}
|
||||
</p>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function XWorkmateWorkspacePage({
|
||||
defaults,
|
||||
}: {
|
||||
@ -447,6 +488,282 @@ export function XWorkmateWorkspacePage({
|
||||
const workspaceName =
|
||||
user?.tenants?.[0]?.name ||
|
||||
pickCopy(isChinese, "默认工作区", "Default workspace");
|
||||
const openclawEndpoint = openclawUrl || defaults.openclawUrl;
|
||||
const vaultEndpoint = vaultUrl || defaults.vaultUrl;
|
||||
const configuredIntegrationCount = integrationRows.filter(
|
||||
(item) => item.ok,
|
||||
).length;
|
||||
|
||||
const tasksOverview = useMemo<OverviewMetric[]>(
|
||||
() => [
|
||||
{
|
||||
label: pickCopy(isChinese, "总数", "Total"),
|
||||
value: selectedSessionKey ? "1+" : "0",
|
||||
caption: pickCopy(
|
||||
isChinese,
|
||||
"从当前会话与对话中派生",
|
||||
"Derived from the current session and chat",
|
||||
),
|
||||
icon: Briefcase,
|
||||
},
|
||||
{
|
||||
label: pickCopy(isChinese, "运行中", "Running"),
|
||||
value: openclawEndpoint.trim()
|
||||
? pickCopy(isChinese, "已就绪", "Ready")
|
||||
: pickCopy(isChinese, "离线", "Offline"),
|
||||
caption: pickCopy(
|
||||
isChinese,
|
||||
"主助手承接实时执行流",
|
||||
"The main assistant owns live execution",
|
||||
),
|
||||
icon: Sparkles,
|
||||
},
|
||||
{
|
||||
label: pickCopy(isChinese, "失败", "Failed"),
|
||||
value:
|
||||
openclawToken.trim() || defaults.openclawTokenConfigured
|
||||
? "0"
|
||||
: "pairing",
|
||||
caption: pickCopy(
|
||||
isChinese,
|
||||
"未配置 shared token 时优先走 pairing",
|
||||
"Pairing is preferred when no shared token is set",
|
||||
),
|
||||
icon: ShieldCheck,
|
||||
},
|
||||
{
|
||||
label: pickCopy(isChinese, "计划中", "Scheduled"),
|
||||
value: pickCopy(isChinese, "预留", "Later"),
|
||||
caption: pickCopy(
|
||||
isChinese,
|
||||
"后续承接 cron / batch 任务",
|
||||
"Reserved for future cron and batch work",
|
||||
),
|
||||
icon: Workflow,
|
||||
},
|
||||
],
|
||||
[
|
||||
defaults.openclawTokenConfigured,
|
||||
isChinese,
|
||||
openclawEndpoint,
|
||||
openclawToken,
|
||||
selectedSessionKey,
|
||||
],
|
||||
);
|
||||
|
||||
const modulesOverview = useMemo<OverviewMetric[]>(
|
||||
() => [
|
||||
{
|
||||
label: "Gateway",
|
||||
value: formatEndpoint(
|
||||
openclawEndpoint,
|
||||
pickCopy(isChinese, "未接入", "Not connected"),
|
||||
),
|
||||
caption: pickCopy(
|
||||
isChinese,
|
||||
"当前公开入口仍由 XWorkmate 统一承接",
|
||||
"The public workspace is still unified under XWorkmate",
|
||||
),
|
||||
icon: Cloud,
|
||||
},
|
||||
{
|
||||
label: pickCopy(isChinese, "节点", "Nodes"),
|
||||
value: pickCopy(isChinese, "控制台", "Console"),
|
||||
caption: pickCopy(
|
||||
isChinese,
|
||||
"节点与加速资源仍由控制台管理",
|
||||
"Nodes and acceleration resources remain in the console",
|
||||
),
|
||||
icon: Waypoints,
|
||||
},
|
||||
{
|
||||
label: pickCopy(isChinese, "代理", "Agents"),
|
||||
value: pickCopy(isChinese, "自动", "Auto"),
|
||||
caption: pickCopy(
|
||||
isChinese,
|
||||
"根据对话内容切换 coding / research / browser",
|
||||
"Switch between coding / research / browser by task",
|
||||
),
|
||||
icon: Bot,
|
||||
},
|
||||
{
|
||||
label: pickCopy(isChinese, "技能", "Skills"),
|
||||
value: pickCopy(isChinese, "截图 + 附件", "Capture + Files"),
|
||||
caption: pickCopy(
|
||||
isChinese,
|
||||
"截图、图片、日志与文本共用同一入口",
|
||||
"Screenshots, images, logs, and text share one flow",
|
||||
),
|
||||
icon: Blocks,
|
||||
},
|
||||
],
|
||||
[isChinese, openclawEndpoint],
|
||||
);
|
||||
|
||||
const secretsOverview = useMemo<OverviewMetric[]>(
|
||||
() => [
|
||||
{
|
||||
label: pickCopy(isChinese, "提供方", "Provider"),
|
||||
value: vaultEndpoint.trim()
|
||||
? "Vault"
|
||||
: pickCopy(isChinese, "会话", "Session"),
|
||||
caption: pickCopy(
|
||||
isChinese,
|
||||
"优先使用 Vault,必要时允许当前会话覆盖",
|
||||
"Prefer Vault, allow session overrides when needed",
|
||||
),
|
||||
icon: Vault,
|
||||
},
|
||||
{
|
||||
label: pickCopy(isChinese, "Token 引用", "Token Refs"),
|
||||
value: `${
|
||||
[
|
||||
openclawToken.trim() || defaults.openclawTokenConfigured,
|
||||
vaultToken.trim() || defaults.vaultTokenConfigured,
|
||||
apisixToken.trim() || defaults.apisixTokenConfigured,
|
||||
].filter(Boolean).length
|
||||
}`,
|
||||
caption: pickCopy(
|
||||
isChinese,
|
||||
"env 与会话级覆盖共同组成引用面",
|
||||
"Refs are composed from env defaults and session overrides",
|
||||
),
|
||||
icon: KeyRound,
|
||||
},
|
||||
{
|
||||
label: pickCopy(isChinese, "密钥引用", "Secret Refs"),
|
||||
value: `${configuredIntegrationCount}/3`,
|
||||
caption: pickCopy(
|
||||
isChinese,
|
||||
"按 OpenClaw / Vault / APISIX 三类集成统计",
|
||||
"Counted across OpenClaw / Vault / APISIX integrations",
|
||||
),
|
||||
icon: ShieldCheck,
|
||||
},
|
||||
{
|
||||
label: pickCopy(isChinese, "最近审计", "Last Audit"),
|
||||
value: pickCopy(isChinese, "当前会话", "This session"),
|
||||
caption: pickCopy(
|
||||
isChinese,
|
||||
"前端只保留脱敏引用,不暴露原始值",
|
||||
"The UI keeps masked refs only and never exposes raw values",
|
||||
),
|
||||
icon: Workflow,
|
||||
},
|
||||
],
|
||||
[
|
||||
apisixToken,
|
||||
configuredIntegrationCount,
|
||||
defaults.apisixTokenConfigured,
|
||||
defaults.openclawTokenConfigured,
|
||||
defaults.vaultTokenConfigured,
|
||||
isChinese,
|
||||
openclawToken,
|
||||
vaultEndpoint,
|
||||
vaultToken,
|
||||
],
|
||||
);
|
||||
|
||||
const settingsOverview = useMemo<OverviewMetric[]>(
|
||||
() => [
|
||||
{
|
||||
label: pickCopy(isChinese, "入口", "Route"),
|
||||
value: "/xworkmate",
|
||||
caption: pickCopy(
|
||||
isChinese,
|
||||
"旧 `/services/openclaw` 只保留兼容跳转",
|
||||
"The old `/services/openclaw` path is compatibility-only",
|
||||
),
|
||||
icon: LayoutDashboard,
|
||||
},
|
||||
{
|
||||
label: pickCopy(isChinese, "外观", "Appearance"),
|
||||
value: resolvedTheme,
|
||||
caption: pickCopy(
|
||||
isChinese,
|
||||
"与站点主题保持一致",
|
||||
"Uses the same theme system as the site",
|
||||
),
|
||||
icon: isDark ? MoonStar : SunMedium,
|
||||
},
|
||||
{
|
||||
label: pickCopy(isChinese, "侧栏", "Sidebar"),
|
||||
value: sidebarState,
|
||||
caption: pickCopy(
|
||||
isChinese,
|
||||
"支持 expanded / collapsed / hidden",
|
||||
"Supports expanded / collapsed / hidden states",
|
||||
),
|
||||
icon: sidebarState === "hidden" ? PanelLeftOpen : PanelLeftClose,
|
||||
},
|
||||
{
|
||||
label: pickCopy(isChinese, "集成", "Integrations"),
|
||||
value: `${configuredIntegrationCount}/3`,
|
||||
caption: pickCopy(
|
||||
isChinese,
|
||||
"OpenClaw、Vault、APISIX 三类入口",
|
||||
"OpenClaw, Vault, and APISIX are all managed here",
|
||||
),
|
||||
icon: Sparkles,
|
||||
},
|
||||
],
|
||||
[
|
||||
configuredIntegrationCount,
|
||||
isChinese,
|
||||
isDark,
|
||||
resolvedTheme,
|
||||
sidebarState,
|
||||
],
|
||||
);
|
||||
|
||||
const accountOverview = useMemo<OverviewMetric[]>(
|
||||
() => [
|
||||
{
|
||||
label: pickCopy(isChinese, "身份", "Identity"),
|
||||
value: accountName,
|
||||
caption: accountEmail,
|
||||
icon: UserCircle2,
|
||||
},
|
||||
{
|
||||
label: pickCopy(isChinese, "工作区", "Workspace"),
|
||||
value: workspaceName,
|
||||
caption: pickCopy(
|
||||
isChinese,
|
||||
"账号页聚焦身份与工作区上下文",
|
||||
"The account area stays focused on identity and workspace context",
|
||||
),
|
||||
icon: Briefcase,
|
||||
},
|
||||
{
|
||||
label: pickCopy(isChinese, "角色", "Role"),
|
||||
value: user?.role || "guest",
|
||||
caption: pickCopy(
|
||||
isChinese,
|
||||
"复用当前控制台会话身份",
|
||||
"Reuses the current console session identity",
|
||||
),
|
||||
icon: ShieldCheck,
|
||||
},
|
||||
{
|
||||
label: pickCopy(isChinese, "会话", "Session"),
|
||||
value: selectedSessionKey || "main",
|
||||
caption: pickCopy(
|
||||
isChinese,
|
||||
"从当前 Gateway 会话中承接上下文",
|
||||
"Context is inherited from the current gateway session",
|
||||
),
|
||||
icon: Workflow,
|
||||
},
|
||||
],
|
||||
[
|
||||
accountEmail,
|
||||
accountName,
|
||||
isChinese,
|
||||
selectedSessionKey,
|
||||
user?.role,
|
||||
workspaceName,
|
||||
],
|
||||
);
|
||||
|
||||
function setTab(nextTab: string): void {
|
||||
setActiveTabs((current) => ({
|
||||
@ -1533,6 +1850,23 @@ export function XWorkmateWorkspacePage({
|
||||
}
|
||||
}
|
||||
|
||||
function renderSectionOverview(): ReactNode {
|
||||
switch (activeSection) {
|
||||
case "tasks":
|
||||
return <OverviewMetrics items={tasksOverview} />;
|
||||
case "modules":
|
||||
return <OverviewMetrics items={modulesOverview} />;
|
||||
case "secrets":
|
||||
return <OverviewMetrics items={secretsOverview} />;
|
||||
case "settings":
|
||||
return <OverviewMetrics items={settingsOverview} />;
|
||||
case "account":
|
||||
return <OverviewMetrics items={accountOverview} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative h-full min-h-0 overflow-hidden rounded-[var(--radius-2xl)] border border-[color:var(--color-surface-border)] bg-[var(--color-surface-elevated)] shadow-[var(--shadow-md)]">
|
||||
{sidebarState === "hidden" ? (
|
||||
@ -1741,7 +2075,10 @@ export function XWorkmateWorkspacePage({
|
||||
</header>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-auto p-5">
|
||||
{renderMainContent()}
|
||||
<div className="space-y-5">
|
||||
{renderSectionOverview()}
|
||||
{renderMainContent()}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@ -73,7 +73,7 @@ export const createNavConfig = (
|
||||
},
|
||||
{
|
||||
key: "chat",
|
||||
label: "XWorkmate",
|
||||
label: isChinese ? "X助手" : "X Assistant",
|
||||
href: "/xworkmate",
|
||||
icon: MessageSquare,
|
||||
active: (pathname) => pathname?.startsWith("/xworkmate"),
|
||||
|
||||
Loading…
Reference in New Issue
Block a user