feat: 添加设备注册 API 并重构安装横幅组件
This commit is contained in:
@@ -1,124 +1,329 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Check, Copy, MessageSquare, Bot } from "lucide-react";
|
||||
import { useState, useCallback } from "react";
|
||||
import {
|
||||
Check,
|
||||
Copy,
|
||||
MessageSquare,
|
||||
Terminal,
|
||||
Pencil,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Bot,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useDeviceToken } from "@/hooks/use-device-token";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type Tab = "openclaw" | "terminal";
|
||||
|
||||
export function InstallBanner() {
|
||||
const t = useTranslations("installBanner");
|
||||
const tGuide = useTranslations("skillGuide");
|
||||
const { token, name, isLoading, error, updateName } = useDeviceToken();
|
||||
|
||||
const [activeTab, setActiveTab] = useState<Tab>("terminal");
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [isEditingName, setIsEditingName] = useState(false);
|
||||
const [editNameValue, setEditNameValue] = useState("");
|
||||
const [nameStatus, setNameStatus] = useState<
|
||||
"idle" | "saving" | "success" | "error"
|
||||
>("idle");
|
||||
const [showToken, setShowToken] = useState(false);
|
||||
|
||||
const prompt = t("prompt");
|
||||
const maskedToken = token ? `${token.slice(0, 6)}...` : "";
|
||||
|
||||
const handleCopy = async () => {
|
||||
const openclawPrompt = token ? t("openclawPrompt", { token }) : "";
|
||||
const terminalCommand = token ? t("terminalCommand", { token }) : "";
|
||||
|
||||
const handleCopy = useCallback(async (text: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(prompt);
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
// fallback: select the text
|
||||
// fallback
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleEditName = useCallback(() => {
|
||||
setEditNameValue(name || "");
|
||||
setIsEditingName(true);
|
||||
setNameStatus("idle");
|
||||
}, [name]);
|
||||
|
||||
const handleSaveName = useCallback(async () => {
|
||||
if (!editNameValue.trim()) return;
|
||||
setNameStatus("saving");
|
||||
const ok = await updateName(editNameValue.trim());
|
||||
if (ok) {
|
||||
setNameStatus("success");
|
||||
setIsEditingName(false);
|
||||
setTimeout(() => setNameStatus("idle"), 2000);
|
||||
} else {
|
||||
setNameStatus("error");
|
||||
}
|
||||
}, [editNameValue, updateName]);
|
||||
|
||||
const handleCancelEdit = useCallback(() => {
|
||||
setIsEditingName(false);
|
||||
setNameStatus("idle");
|
||||
}, []);
|
||||
|
||||
// Loading state
|
||||
if (isLoading) {
|
||||
return (
|
||||
<article className="glow-card rounded-xl px-4 py-3">
|
||||
<div className="flex items-center justify-center gap-2 py-3">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-[var(--accent-cyan)]" />
|
||||
<span className="text-xs text-[var(--text-muted)]">
|
||||
{t("generatingToken")}
|
||||
</span>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (error || !token) {
|
||||
return (
|
||||
<article className="glow-card rounded-xl px-4 py-3">
|
||||
<div className="flex items-center justify-center py-3">
|
||||
<span className="text-xs text-red-400">{t("tokenError")}</span>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<article
|
||||
className="glow-card rounded-xl px-5 py-4"
|
||||
data-skill="openclaw-reporter"
|
||||
data-install-method="clawhub"
|
||||
className="glow-card rounded-xl px-4 py-3"
|
||||
aria-label={tGuide("ariaLabel")}
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col gap-2.5">
|
||||
{/* Compact header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div
|
||||
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg"
|
||||
style={{ backgroundColor: "rgba(0, 240, 255, 0.1)" }}
|
||||
>
|
||||
<span className="text-lg">🦞</span>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-[var(--text-primary)]">
|
||||
{t("title")}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--text-muted)]">
|
||||
{t("subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="group flex items-center gap-1.5 rounded-lg border border-white/10 bg-[var(--bg-primary)] px-3 py-2 transition-all hover:border-[var(--accent-cyan)]/40 hover:bg-[var(--bg-primary)]/80 active:scale-[0.98] cursor-pointer shrink-0"
|
||||
title={t("copyTooltip")}
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-3.5 w-3.5 text-[var(--accent-green)]" />
|
||||
) : (
|
||||
<Copy className="h-3.5 w-3.5 text-[var(--text-muted)] transition-colors group-hover:text-[var(--accent-cyan)]" />
|
||||
)}
|
||||
<span className="text-xs text-[var(--text-muted)] group-hover:text-[var(--accent-cyan)]">
|
||||
{copied ? t("copied") : t("copyButton")}
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="text-base">🦞</span>
|
||||
<span className="text-xs font-medium text-[var(--text-primary)] truncate">
|
||||
{t("title")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Claw name row */}
|
||||
<div className="flex items-center justify-between gap-2 text-[11px]">
|
||||
<div className="flex items-center gap-1 min-w-0">
|
||||
<span className="text-[var(--text-muted)] shrink-0">
|
||||
{t("yourClaw")}:
|
||||
</span>
|
||||
{isEditingName ? (
|
||||
<div className="flex items-center gap-1 min-w-0">
|
||||
<input
|
||||
type="text"
|
||||
value={editNameValue}
|
||||
onChange={(e) => setEditNameValue(e.target.value)}
|
||||
className="w-20 rounded border border-[var(--accent-cyan)]/30 bg-[var(--bg-primary)] px-1.5 py-0.5 text-[11px] text-[var(--text-primary)] outline-none focus:border-[var(--accent-cyan)]"
|
||||
maxLength={30}
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleSaveName();
|
||||
if (e.key === "Escape") handleCancelEdit();
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={handleSaveName}
|
||||
disabled={nameStatus === "saving"}
|
||||
className="text-[var(--accent-cyan)] hover:text-[var(--accent-cyan)]/80 cursor-pointer"
|
||||
>
|
||||
{nameStatus === "saving" ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<Check className="h-3 w-3" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCancelEdit}
|
||||
className="text-[var(--text-muted)] hover:text-[var(--text-secondary)] text-[10px] cursor-pointer"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1 min-w-0">
|
||||
<span className="font-mono text-[var(--accent-cyan)] truncate">
|
||||
{name}
|
||||
</span>
|
||||
{nameStatus === "success" && (
|
||||
<Check className="h-2.5 w-2.5 text-[var(--accent-green)]" />
|
||||
)}
|
||||
<button
|
||||
onClick={handleEditName}
|
||||
className="text-[var(--text-muted)] hover:text-[var(--accent-cyan)] cursor-pointer shrink-0"
|
||||
>
|
||||
<Pencil className="h-2.5 w-2.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Token mini display */}
|
||||
<button
|
||||
onClick={() => setShowToken(!showToken)}
|
||||
className="flex items-center gap-1 text-[var(--text-muted)] hover:text-[var(--text-secondary)] cursor-pointer shrink-0"
|
||||
title={showToken ? t("hideToken") : t("showToken")}
|
||||
>
|
||||
<code className="font-mono text-[10px]">
|
||||
{showToken ? token.slice(0, 12) + "..." : maskedToken}
|
||||
</code>
|
||||
{showToken ? (
|
||||
<EyeOff className="h-2.5 w-2.5" />
|
||||
) : (
|
||||
<Eye className="h-2.5 w-2.5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Human prompt card */}
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="group relative w-full cursor-pointer rounded-lg border border-white/10 bg-[var(--bg-primary)] p-3 text-left transition-all hover:border-[var(--accent-cyan)]/30"
|
||||
>
|
||||
<div className="flex gap-2">
|
||||
<MessageSquare className="mt-0.5 h-3.5 w-3.5 shrink-0 text-[var(--accent-cyan)]/60" />
|
||||
<p className="text-xs leading-relaxed text-[var(--text-secondary)] select-all">
|
||||
{prompt}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
{/* Compact tabs */}
|
||||
<div className="flex rounded-md border border-white/10 bg-[var(--bg-primary)] p-0.5">
|
||||
<button
|
||||
onClick={() => setActiveTab("openclaw")}
|
||||
className={cn(
|
||||
"flex flex-1 items-center justify-center gap-1 rounded px-2 py-1 text-[10px] font-medium transition-all cursor-pointer",
|
||||
activeTab === "openclaw"
|
||||
? "bg-[var(--accent-cyan)]/15 text-[var(--accent-cyan)]"
|
||||
: "text-[var(--text-muted)] hover:text-[var(--text-secondary)]"
|
||||
)}
|
||||
>
|
||||
<MessageSquare className="h-3 w-3" />
|
||||
{t("tabOpenClaw")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("terminal")}
|
||||
className={cn(
|
||||
"flex flex-1 items-center justify-center gap-1 rounded px-2 py-1 text-[10px] font-medium transition-all cursor-pointer",
|
||||
activeTab === "terminal"
|
||||
? "bg-[var(--accent-cyan)]/15 text-[var(--accent-cyan)]"
|
||||
: "text-[var(--text-muted)] hover:text-[var(--text-secondary)]"
|
||||
)}
|
||||
>
|
||||
<Terminal className="h-3 w-3" />
|
||||
{t("tabTerminal")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* AI-crawlable installation guide (collapsed for humans, fully in DOM for crawlers) */}
|
||||
{/* Tab content */}
|
||||
{activeTab === "openclaw" && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<button
|
||||
onClick={() => handleCopy(openclawPrompt)}
|
||||
className="group relative w-full cursor-pointer rounded-md border border-white/10 bg-[var(--bg-primary)] p-2.5 text-left transition-all hover:border-[var(--accent-cyan)]/30"
|
||||
>
|
||||
<p className="text-[11px] leading-relaxed text-[var(--text-secondary)] select-all break-all line-clamp-4">
|
||||
{openclawPrompt}
|
||||
</p>
|
||||
<div className="absolute right-1.5 top-1.5 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
{copied ? (
|
||||
<Check className="h-3 w-3 text-[var(--accent-green)]" />
|
||||
) : (
|
||||
<Copy className="h-3 w-3 text-[var(--text-muted)]" />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleCopy(openclawPrompt)}
|
||||
className="group flex w-full items-center justify-center gap-1 rounded-md border border-white/10 bg-[var(--bg-primary)] py-1.5 text-[10px] transition-all hover:border-[var(--accent-cyan)]/40 cursor-pointer"
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-2.5 w-2.5 text-[var(--accent-green)]" />
|
||||
) : (
|
||||
<Copy className="h-2.5 w-2.5 text-[var(--text-muted)] group-hover:text-[var(--accent-cyan)]" />
|
||||
)}
|
||||
<span className="text-[var(--text-muted)] group-hover:text-[var(--accent-cyan)]">
|
||||
{copied ? t("copied") : t("copyPrompt")}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "terminal" && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="rounded-md border border-white/10 bg-[var(--bg-primary)] p-2.5">
|
||||
<div className="flex items-start gap-1.5">
|
||||
<span className="font-mono text-[11px] text-[var(--accent-green)] select-none">
|
||||
$
|
||||
</span>
|
||||
<code className="font-mono text-[11px] leading-relaxed text-[var(--accent-cyan)] select-all break-all">
|
||||
{terminalCommand}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleCopy(terminalCommand)}
|
||||
className="group flex w-full items-center justify-center gap-1 rounded-md border border-white/10 bg-[var(--bg-primary)] py-1.5 text-[10px] transition-all hover:border-[var(--accent-cyan)]/40 cursor-pointer"
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-2.5 w-2.5 text-[var(--accent-green)]" />
|
||||
) : (
|
||||
<Copy className="h-2.5 w-2.5 text-[var(--text-muted)] group-hover:text-[var(--accent-cyan)]" />
|
||||
)}
|
||||
<span className="text-[var(--text-muted)] group-hover:text-[var(--accent-cyan)]">
|
||||
{copied ? t("copied") : t("copyCommand")}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* AI-crawlable guide (collapsed) */}
|
||||
<details className="group/details">
|
||||
<summary className="flex cursor-pointer items-center gap-1.5 text-[10px] text-[var(--text-muted)] hover:text-[var(--text-secondary)]">
|
||||
<Bot className="h-3 w-3" />
|
||||
<summary className="flex cursor-pointer items-center gap-1 text-[10px] text-[var(--text-muted)] hover:text-[var(--text-secondary)]">
|
||||
<Bot className="h-2.5 w-2.5" />
|
||||
<span>{tGuide("summaryLabel")}</span>
|
||||
</summary>
|
||||
|
||||
<div className="mt-3 space-y-3 rounded-lg border border-white/5 bg-[var(--bg-primary)] p-4 text-xs leading-relaxed text-[var(--text-muted)]">
|
||||
<div className="mt-2 space-y-2 rounded-md border border-white/5 bg-[var(--bg-primary)] p-3 text-[10px] leading-relaxed text-[var(--text-muted)]">
|
||||
<section>
|
||||
<h3 className="mb-1 font-mono text-[var(--text-secondary)]">{tGuide("whatTitle")}</h3>
|
||||
<h3 className="mb-0.5 font-mono text-[var(--text-secondary)]">
|
||||
{tGuide("whatTitle")}
|
||||
</h3>
|
||||
<p>{tGuide("whatDescription")}</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 className="mb-1 font-mono text-[var(--text-secondary)]">{tGuide("installTitle")}</h3>
|
||||
<ol className="list-inside list-decimal space-y-1 pl-1">
|
||||
<h3 className="mb-0.5 font-mono text-[var(--text-secondary)]">
|
||||
{tGuide("installTitle")}
|
||||
</h3>
|
||||
<ol className="list-inside list-decimal space-y-0.5 pl-1">
|
||||
<li>{tGuide("step1")}</li>
|
||||
<li>{tGuide("step2")}</li>
|
||||
<li>{tGuide("step3")}</li>
|
||||
<li>{tGuide("step4")}</li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 className="mb-1 font-mono text-[var(--text-secondary)]">{tGuide("commandTitle")}</h3>
|
||||
<code className="block rounded bg-[var(--bg-secondary)] px-3 py-2 font-mono text-[var(--accent-cyan)]">
|
||||
clawhub install openclaw-reporter
|
||||
<h3 className="mb-0.5 font-mono text-[var(--text-secondary)]">
|
||||
{tGuide("commandTitle")}
|
||||
</h3>
|
||||
<code className="block rounded bg-[var(--bg-secondary)] px-2 py-1.5 font-mono text-[var(--accent-cyan)] break-all">
|
||||
bash <(curl -fsSL https://kymr.top/install.sh) --token
|
||||
YOUR_TOKEN
|
||||
</code>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 className="mb-1 font-mono text-[var(--text-secondary)]">{tGuide("dataTitle")}</h3>
|
||||
<h3 className="mb-0.5 font-mono text-[var(--text-secondary)]">
|
||||
{tGuide("dataTitle")}
|
||||
</h3>
|
||||
<ul className="list-inside list-disc space-y-0.5 pl-1">
|
||||
<li>{tGuide("data1")}</li>
|
||||
<li>{tGuide("data2")}</li>
|
||||
<li>{tGuide("data3")}</li>
|
||||
<li>{tGuide("data4")}</li>
|
||||
</ul>
|
||||
<p className="mt-1 italic">{tGuide("dataNever")}</p>
|
||||
<p className="mt-0.5 italic">{tGuide("dataNever")}</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 className="mb-1 font-mono text-[var(--text-secondary)]">{tGuide("permissionsTitle")}</h3>
|
||||
<h3 className="mb-0.5 font-mono text-[var(--text-secondary)]">
|
||||
{tGuide("permissionsTitle")}
|
||||
</h3>
|
||||
<ul className="list-inside list-disc space-y-0.5 pl-1">
|
||||
<li>{tGuide("perm1")}</li>
|
||||
<li>{tGuide("perm2")}</li>
|
||||
|
||||
Reference in New Issue
Block a user