Refactor homepage into prompt-first workspace

This commit is contained in:
Haitao Pan 2026-03-18 15:19:14 +08:00
parent 00023b808b
commit 9e452ca464
9 changed files with 1205 additions and 354 deletions

View File

@ -25,9 +25,14 @@ export function AppProviders({
const isOpenClawWorkspace =
pathname.startsWith("/xworkmate") ||
pathname.startsWith("/services/openclaw");
const isHomepage = pathname === "/";
const reserveSpace =
!isOpenClawWorkspace && isOpen && !isMinimized && !isMobileViewport;
!isOpenClawWorkspace &&
!isHomepage &&
isOpen &&
!isMinimized &&
!isMobileViewport;
useEffect(() => {
setScope("global", assistantDefaults);
@ -76,7 +81,7 @@ export function AppProviders({
<div className="flex-1 flex flex-col w-full relative">
{children}
</div>
{!isOpenClawWorkspace ? (
{!isOpenClawWorkspace && !isHomepage ? (
<AskAIDialog
open={isOpen}
defaults={assistantDefaults}

View File

@ -0,0 +1,117 @@
export const dynamic = "force-dynamic";
import { NextRequest, NextResponse } from "next/server";
import { getAccountServiceApiBaseUrl } from "@server/serviceConfig";
import {
getAccountSession,
userHasPermission,
userHasRole,
userHasRoleOrPermission,
} from "@server/account/session";
import type { AccountUserRole } from "@server/account/session";
const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl();
const READ_ROLES: AccountUserRole[] = ["admin", "operator"];
const WRITE_ROLES: AccountUserRole[] = ["admin"];
const READ_PERMISSIONS = ["admin.settings.read"];
type ErrorPayload = {
error: string;
};
async function proxyAccountRequest(
request: NextRequest,
endpoint: string,
method: string,
token: string,
) {
const headers = new Headers({
Authorization: `Bearer ${token}`,
Accept: "application/json",
});
let body: string | undefined;
if (method !== "GET" && method !== "HEAD") {
body = await request.text();
headers.set(
"Content-Type",
request.headers.get("content-type") ?? "application/json",
);
}
const response = await fetch(endpoint, {
method,
headers,
body,
cache: "no-store",
});
const payload = await response.json().catch(() => null);
if (payload === null) {
return NextResponse.json<ErrorPayload>(
{ error: "invalid_response" },
{ status: 502 },
);
}
return NextResponse.json(payload, { status: response.status });
}
export async function GET(request: NextRequest) {
const session = await getAccountSession(request);
const user = session.user;
if (!user || !session.token) {
return NextResponse.json<ErrorPayload>(
{ error: "unauthenticated" },
{ status: 401 },
);
}
if (!(await userHasRoleOrPermission(user, READ_ROLES, READ_PERMISSIONS))) {
return NextResponse.json<ErrorPayload>(
{ error: "forbidden" },
{ status: 403 },
);
}
return proxyAccountRequest(
request,
`${ACCOUNT_API_BASE}/admin/homepage-video`,
"GET",
session.token,
);
}
export async function PUT(request: NextRequest) {
const session = await getAccountSession(request);
const user = session.user;
if (!user || !session.token) {
return NextResponse.json<ErrorPayload>(
{ error: "unauthenticated" },
{ status: 401 },
);
}
if (
!(
(await userHasRole(user, WRITE_ROLES)) ||
(await userHasPermission(user, ["admin.settings.write"]))
)
) {
return NextResponse.json<ErrorPayload>(
{ error: "forbidden" },
{ status: 403 },
);
}
return proxyAccountRequest(
request,
`${ACCOUNT_API_BASE}/admin/homepage-video`,
"PUT",
session.token,
);
}

View File

@ -0,0 +1,40 @@
export const dynamic = "force-dynamic";
import { NextRequest, NextResponse } from "next/server";
import { getAccountServiceApiBaseUrl } from "@/server/serviceConfig";
const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl();
export async function GET(request: NextRequest) {
try {
const headers = new Headers({
Accept: "application/json",
});
const requestHost =
request.headers.get("x-forwarded-host") ?? request.headers.get("host");
if (requestHost?.trim()) {
headers.set("X-Forwarded-Host", requestHost.trim());
}
const response = await fetch(`${ACCOUNT_API_BASE}/homepage-video`, {
method: "GET",
headers,
cache: "no-store",
});
const payload = await response.json().catch(() => null);
if (payload === null) {
return NextResponse.json({ error: "invalid_response" }, { status: 502 });
}
return NextResponse.json(payload, { status: response.status });
} catch (error) {
console.error("homepage video proxy failed", error);
return NextResponse.json(
{ error: "account_service_unreachable" },
{ status: 502 },
);
}
}

View File

@ -2,17 +2,16 @@
export const dynamic = "error";
import { useState } from "react";
import {
AppWindow,
ArrowRight,
BookOpen,
Bot,
Command,
Layers,
Link,
Lock,
MousePointerClick,
Play,
PlusCircle,
ShieldCheck,
Sparkles,
Terminal,
@ -20,14 +19,18 @@ import {
} from "lucide-react";
import useSWR from "swr";
import { OpenClawAssistantPane } from "@/components/openclaw/OpenClawAssistantPane";
import type { IntegrationDefaults } from "@/lib/openclaw/types";
import Footer from "../components/Footer";
import UnifiedNavigation from "../components/UnifiedNavigation";
import { useLanguage } from "../i18n/LanguageProvider";
import { translations } from "../i18n/translations";
import {
heroVideoMedia,
type HeroVideoMedia,
} from "../lib/home/heroVideoMedia";
DEFAULT_HOMEPAGE_VIDEO_SETTINGS,
resolveHomepageVideoPresentation,
type HomepageVideoPresentation,
type ResolvedHomepageVideoResponse,
} from "../lib/home/homepageVideo";
import { useMoltbotStore } from "../lib/moltbotStore";
import { useUserStore } from "../lib/userStore";
import { cn } from "../lib/utils";
@ -38,6 +41,18 @@ const HOME_SECTION_LABEL_CLASS =
"text-[0.68rem] font-semibold uppercase tracking-[0.26em] text-text-subtle";
const HOME_LIST_CARD_CLASS =
"rounded-[1.5rem] border border-slate-900/10 bg-[#fcfbf8] transition duration-200";
const EMPTY_ASSISTANT_DEFAULTS: IntegrationDefaults = {
openclawUrl: "",
openclawOrigin: "",
openclawTokenConfigured: false,
vaultUrl: "",
vaultNamespace: "",
vaultTokenConfigured: false,
vaultSecretPath: "",
vaultSecretKey: "",
apisixUrl: "",
apisixTokenConfigured: false,
};
const iconMap: Record<string, any> = {
"Global Acceleration Network": Link,
@ -71,6 +86,33 @@ const iconMap: Record<string, any> = {
const getIcon = (key: string, fallback: any) => iconMap[key] || fallback;
async function jsonFetcher<T>(
input: RequestInfo,
init?: RequestInit,
): Promise<T> {
const response = await fetch(input, {
...init,
credentials: "include",
headers: {
Accept: "application/json",
...(init?.headers instanceof Headers
? Object.fromEntries(init.headers.entries())
: init?.headers),
},
cache: "no-store",
});
if (!response.ok) {
const payload = (await response.json().catch(() => ({}))) as {
error?: string;
message?: string;
};
throw new Error(payload.error ?? payload.message ?? "请求失败");
}
return (await response.json()) as T;
}
export default function HomePage() {
const { mode, isOpen } = useMoltbotStore();
@ -92,6 +134,7 @@ export default function HomePage() {
<div className="relative mx-auto max-w-6xl px-4 pb-16 sm:px-6 sm:pb-20">
<main className="relative space-y-6 pt-6 sm:space-y-8 sm:pt-10">
<HeroSection />
<ProductDemoSection />
<NextStepsSection />
<StatsSection />
<ShortcutsSection />
@ -110,7 +153,54 @@ export function HeroSection() {
const { user } = useUserStore();
const { language } = useLanguage();
const isChinese = language === "zh";
const t = translations[language].marketing.home;
const [promptSeed, setPromptSeed] = useState("");
const [promptSeedKey, setPromptSeedKey] = useState(0);
const assistantDefaultsSWR = useSWR<IntegrationDefaults>(
"/api/integrations/defaults",
jsonFetcher,
{
revalidateOnFocus: false,
},
);
const heroCopy = isChinese
? {
eyebrow: "AI Native Workspace",
title: "直接说出你的需求,剩下的交给 AI",
subtitle: "从想法到上线AI 自动完成构建、部署与优化。",
description:
"从 xstream 到 xworkmate再到 console.svc.plus用一次对话串起构建、部署、排障和运营。",
status: user
? `当前模式:${user.username} · 可直接发起任务`
: "当前模式Guest · 可直接体验",
examplesTitle: "你可以这样开始",
examplesHint: "点击任一示例后,会直接填入右侧输入框。",
examples: [
"帮我构建一个 SaaS 应用",
"分析这个报错并给出修复建议",
"生成一个 AI agent workflow",
"帮我设计一个控制台首页",
],
}
: {
eyebrow: "AI Native Workspace",
title: "Describe what you need. Let AI handle the rest.",
subtitle:
"From idea to launch, AI can assemble, deploy, and optimize the work.",
description:
"From xstream to xworkmate to console.svc.plus, one conversation can carry the whole workflow.",
status: user
? `Mode: ${user.username} · Ready to execute`
: "Mode: Guest · Ready to explore",
examplesTitle: "Try starting with",
examplesHint: "Click a prompt to fill the composer on the right.",
examples: [
"Help me build a SaaS app",
"Analyze this error and suggest a fix",
"Generate an AI agent workflow",
"Design a console homepage",
],
};
return (
<section className="relative overflow-hidden rounded-[2.75rem] border border-slate-900/10 bg-[linear-gradient(180deg,#ffffff,#faf7f2)] p-6 shadow-[0_24px_56px_rgba(15,23,42,0.05)] sm:p-8 lg:p-10">
@ -122,64 +212,107 @@ export function HeroSection() {
</div>
<div className="relative grid gap-8 lg:grid-cols-[0.96fr_1.04fr] lg:gap-12">
<div className="flex flex-col justify-between gap-8">
<div className="space-y-5">
{t.hero.eyebrow ? (
<p className={HOME_SECTION_LABEL_CLASS}>{t.hero.eyebrow}</p>
) : null}
<div className="flex flex-col justify-between gap-8 pt-2">
<div className="space-y-6">
<p className={HOME_SECTION_LABEL_CLASS}>{heroCopy.eyebrow}</p>
<h1
className={cn(
"max-w-[11ch] leading-[0.88] text-heading",
"max-w-[10ch] leading-[0.94] text-heading",
isChinese
? "text-[2.85rem] font-semibold tracking-[-0.08em] sm:text-[3.4rem] lg:text-[4.5rem]"
: "editorial-display text-[3.05rem] tracking-[-0.06em] sm:text-[3.6rem] lg:text-[4.8rem]",
? "text-[3.05rem] font-semibold tracking-[-0.055em] sm:text-[3.6rem] lg:text-[4.2rem]"
: "editorial-display text-[3rem] tracking-[-0.05em] sm:text-[3.5rem] lg:text-[4.3rem]",
)}
>
{t.hero.title}
{heroCopy.title}
</h1>
<p className="max-w-xl text-[1rem] leading-8 text-text-muted sm:text-[1.05rem]">
{t.hero.subtitle}
</p>
<div className="max-w-xl space-y-3">
<p className="text-[1rem] leading-8 text-text-muted sm:text-[1.08rem]">
{heroCopy.subtitle}
</p>
<p className="text-sm leading-7 text-slate-500 sm:text-[0.98rem]">
{heroCopy.description}
</p>
<p className="text-xs font-medium tracking-[0.02em] text-slate-500">
{heroCopy.status}
</p>
</div>
</div>
<div className="flex flex-wrap gap-3">
{user ? (
<div className="inline-flex items-center gap-2 rounded-full border border-success/25 bg-success/10 px-4 py-2 text-sm font-semibold text-success">
<div className="h-2 w-2 rounded-full bg-success animate-pulse" />
{t.signedIn.replace("{{username}}", user.username)}
<div className="space-y-4">
<div className="flex items-center gap-2">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-slate-950 text-white">
<Bot className="h-4 w-4" />
</div>
) : (
<button
type="button"
className="inline-flex items-center gap-2 rounded-full bg-slate-950 px-6 py-3 text-sm font-semibold text-white transition hover:bg-primary"
>
<PlusCircle className="h-4 w-4" />
{t.heroButtons.create}
</button>
)}
<button
type="button"
className="inline-flex items-center gap-2 rounded-full border border-slate-900/10 bg-white px-6 py-3 text-sm font-semibold text-slate-800 transition hover:bg-slate-50"
>
<Play className="h-4 w-4" />
{t.heroButtons.playground}
</button>
<button
type="button"
className="inline-flex items-center gap-2 rounded-full border border-slate-900/10 bg-[#f8f4ec] px-6 py-3 text-sm font-semibold text-slate-800 transition hover:bg-[#f2ebdd]"
>
<BookOpen className="h-4 w-4" />
{t.heroButtons.tutorials}
</button>
<div>
<h2 className="text-sm font-semibold text-slate-900">
{heroCopy.examplesTitle}
</h2>
<p className="text-xs text-slate-500">
{heroCopy.examplesHint}
</p>
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2">
{heroCopy.examples.map((example) => (
<button
key={example}
type="button"
onClick={() => {
setPromptSeed(example);
setPromptSeedKey((current) => current + 1);
}}
className="group rounded-[1.35rem] border border-slate-200 bg-white/90 px-4 py-4 text-left shadow-[0_12px_30px_rgba(15,23,42,0.04)] transition duration-200 hover:-translate-y-[1px] hover:border-slate-300 hover:bg-[#fbfaf7]"
>
<div className="flex items-start justify-between gap-3">
<div className="space-y-2">
<p className="text-sm font-semibold leading-6 text-slate-900">
{example}
</p>
<p className="text-xs leading-5 text-slate-500">
{isChinese
? "点击后填入右侧输入框,不会自动发送。"
: "Click to fill the composer on the right. It will not auto-submit."}
</p>
</div>
<ArrowRight className="mt-0.5 h-4 w-4 shrink-0 text-slate-400 transition group-hover:text-slate-700" />
</div>
</button>
))}
</div>
</div>
</div>
<div className="lg:pl-4">
<HeroVideoShell
items={t.heroCards.map((card) => card.title)}
isChinese={isChinese}
media={heroVideoMedia}
/>
<div className="overflow-hidden rounded-[2rem] border border-slate-900/10 bg-[linear-gradient(180deg,rgba(255,255,255,0.94),rgba(246,248,251,0.98))] shadow-[0_24px_60px_rgba(15,23,42,0.08)]">
<div className="border-b border-slate-900/10 px-5 py-4 sm:px-6">
<div className="flex items-start justify-between gap-4">
<div>
<p className={HOME_SECTION_LABEL_CLASS}>
{isChinese ? "X 助手" : "X Assistant"}
</p>
<p className="mt-2 max-w-md text-sm leading-6 text-text-muted">
{isChinese
? "首页只保留一个主路径:先提问,再由助手拆解任务、调用能力并推进执行。"
: "The homepage keeps one primary path: ask first, then let the assistant plan and execute."}
</p>
</div>
<span className="hidden rounded-full border border-slate-900/10 bg-white/90 px-3 py-1 text-xs font-semibold text-slate-600 sm:inline-flex">
{isChinese ? "对话即入口" : "Prompt-first"}
</span>
</div>
</div>
<div className="p-4 sm:p-5">
<OpenClawAssistantPane
defaults={assistantDefaultsSWR.data ?? EMPTY_ASSISTANT_DEFAULTS}
initialQuestion={promptSeed}
initialQuestionKey={promptSeedKey}
autoSubmitInitialQuestion={false}
variant="page"
/>
</div>
</div>
</div>
</div>
</section>
@ -465,30 +598,21 @@ type LatestBlogPost = {
date?: string;
};
function HeroVideoShell({
items,
isChinese,
media,
}: {
items: string[];
isChinese: boolean;
media: HeroVideoMedia;
}) {
const mediaTitle = isChinese ? media.title.zh : media.title.en;
const mediaDescription = isChinese
? media.description.zh
: media.description.en;
const mediaStatusLabel = isChinese
? media.statusLabel.zh
: media.statusLabel.en;
const hasVideo = Boolean(media.videoUrl);
const previewStyle = media.posterUrl
? {
backgroundImage: `linear-gradient(180deg,rgba(15,23,42,0.18),rgba(15,23,42,0.52)), url(${media.posterUrl})`,
backgroundSize: "cover",
backgroundPosition: "center",
}
: undefined;
function ProductDemoSection() {
const { language } = useLanguage();
const isChinese = language === "zh";
const { data } = useSWR<ResolvedHomepageVideoResponse>(
"/api/homepage-video",
jsonFetcher,
{
fallbackData: {
resolved: DEFAULT_HOMEPAGE_VIDEO_SETTINGS.defaultEntry,
},
revalidateOnFocus: false,
},
);
const entry = data?.resolved ?? DEFAULT_HOMEPAGE_VIDEO_SETTINGS.defaultEntry;
const presentation = resolveHomepageVideoPresentation(entry);
return (
<div className="overflow-hidden rounded-[2rem] border border-slate-900/10 bg-[linear-gradient(180deg,rgba(255,255,255,0.88),rgba(244,247,252,0.96))] shadow-[0_24px_60px_rgba(15,23,42,0.08)]">
@ -500,111 +624,106 @@ function HeroVideoShell({
</p>
<p className="mt-2 max-w-md text-sm leading-6 text-text-muted">
{isChinese
? "这里预留为视频展示区,后续可以直接替换成产品介绍、工作流演示或 onboarding 视频。"
: "Reserved for a video showcase. You can later replace it with a product intro, workflow demo, or onboarding clip."}
? "这里展示当前域名对应的产品演示链接。主站默认走 YouTube中国站可切到 Bilibili也可以继续按域名覆盖。"
: "This section resolves the product demo for the current host. The default can use YouTube while regional hosts override it."}
</p>
</div>
<span className="hidden rounded-full border border-slate-900/10 bg-white/90 px-3 py-1 text-xs font-semibold text-slate-600 sm:inline-flex">
{isChinese ? "16:9 占位" : "16:9 shell"}
{isChinese ? "按域名解析" : "Domain aware"}
</span>
</div>
</div>
<div className="space-y-4 p-4 sm:p-5">
<div
className={cn(
"group relative aspect-video overflow-hidden rounded-[1.6rem] border border-slate-900/10",
!hasVideo &&
"bg-[radial-gradient(circle_at_top_left,rgba(51,102,255,0.16),transparent_34%),linear-gradient(135deg,#0f172a,#172033_52%,#1f2d4d)]",
)}
style={previewStyle}
>
{hasVideo ? (
<video
className="h-full w-full object-cover"
controls
playsInline
preload="metadata"
poster={media.posterUrl || undefined}
>
<source src={media.videoUrl} />
</video>
) : (
<>
<div
aria-hidden
className="absolute inset-0 bg-[linear-gradient(180deg,rgba(255,255,255,0.04),rgba(15,23,42,0.18))]"
/>
<div
aria-hidden
className="absolute left-5 top-5 h-20 w-20 rounded-full bg-[radial-gradient(circle,rgba(255,255,255,0.18),transparent_70%)] blur-2xl"
/>
<div
aria-hidden
className="absolute right-[-1.5rem] top-[-1.5rem] h-28 w-28 rounded-full border border-white/10"
/>
</>
)}
<div className="absolute inset-0 flex flex-col justify-between p-5 sm:p-6">
<div className="flex items-center justify-between gap-3">
<span className="inline-flex items-center gap-2 rounded-full border border-white/15 bg-white/10 px-3 py-1.5 text-[11px] font-semibold uppercase tracking-[0.2em] text-white/80 backdrop-blur">
<span className="h-2 w-2 rounded-full bg-emerald-400" />
{mediaStatusLabel}
</span>
<span className="rounded-full border border-white/10 bg-black/10 px-3 py-1 text-xs font-medium text-white/70">
{media.durationLabel}
</span>
</div>
<div className="space-y-4">
{!hasVideo ? (
<button
type="button"
className="inline-flex h-16 w-16 items-center justify-center rounded-full border border-white/15 bg-white/12 text-white shadow-[0_14px_35px_rgba(15,23,42,0.25)] backdrop-blur transition group-hover:scale-[1.02]"
>
<Play className="ml-1 h-7 w-7" fill="currentColor" />
</button>
) : null}
<div className="max-w-lg space-y-2">
<p className="text-xl font-semibold tracking-[-0.03em] text-white sm:text-2xl">
{mediaTitle}
</p>
<p className="text-sm leading-6 text-white/72 sm:text-[0.95rem]">
{mediaDescription}
</p>
</div>
</div>
<div className="space-y-3">
<div className="h-1.5 overflow-hidden rounded-full bg-white/12">
<div className="h-full w-[28%] rounded-full bg-white/75" />
</div>
<div className="grid grid-cols-3 gap-2 text-[11px] font-medium text-white/60 sm:text-xs">
{media.chapters.map((chapter) => (
<span
key={chapter.en}
className="rounded-full border border-white/10 bg-white/8 px-3 py-2 text-center"
>
{isChinese ? chapter.zh : chapter.en}
</span>
))}
</div>
</div>
</div>
</div>
<div className="flex flex-wrap gap-2">
{items.map((item) => (
<span
key={item}
className="inline-flex items-center rounded-full border border-slate-900/10 bg-white px-3 py-1.5 text-xs font-semibold text-slate-600"
>
{item}
</span>
))}
<DemoVideoSurface presentation={presentation} isChinese={isChinese} />
<div className="flex flex-wrap gap-2 text-xs text-slate-500">
<span className="inline-flex items-center rounded-full border border-slate-900/10 bg-white px-3 py-1.5 font-semibold text-slate-600">
{entry.domain?.trim()
? `${isChinese ? "当前域名" : "Host"}: ${entry.domain}`
: isChinese
? "默认主站配置"
: "Default site config"}
</span>
<a
href={entry.videoUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center rounded-full border border-slate-900/10 bg-white px-3 py-1.5 font-semibold text-slate-600 transition hover:border-slate-300 hover:text-slate-900"
>
{isChinese ? "打开原始链接" : "Open source link"}
</a>
</div>
</div>
</div>
);
}
function DemoVideoSurface({
presentation,
isChinese,
}: {
presentation: HomepageVideoPresentation;
isChinese: boolean;
}) {
const fallbackStyle =
presentation.posterUrl && presentation.kind === "empty"
? {
backgroundImage: `linear-gradient(180deg,rgba(15,23,42,0.16),rgba(15,23,42,0.42)), url(${presentation.posterUrl})`,
backgroundSize: "cover",
backgroundPosition: "center",
}
: undefined;
return (
<div
className={cn(
"group relative aspect-video overflow-hidden rounded-[1.6rem] border border-slate-900/10 bg-[radial-gradient(circle_at_top_left,rgba(51,102,255,0.16),transparent_34%),linear-gradient(135deg,#0f172a,#172033_52%,#1f2d4d)]",
presentation.kind !== "empty" && "bg-slate-950",
)}
style={fallbackStyle}
>
{presentation.kind === "embed" ? (
<iframe
src={presentation.src}
title={isChinese ? "产品演示视频" : "Product demo video"}
className="h-full w-full"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowFullScreen
/>
) : null}
{presentation.kind === "direct" ? (
<video
className="h-full w-full object-cover"
controls
playsInline
preload="metadata"
poster={presentation.posterUrl || undefined}
>
<source src={presentation.src} />
</video>
) : null}
{presentation.kind === "empty" ? (
<div className="absolute inset-0 flex flex-col justify-between p-5 sm:p-6">
<div className="inline-flex w-fit items-center gap-2 rounded-full border border-white/15 bg-white/10 px-3 py-1.5 text-[11px] font-semibold uppercase tracking-[0.2em] text-white/80 backdrop-blur">
<span className="h-2 w-2 rounded-full bg-amber-300" />
{isChinese ? "待接入视频" : "Awaiting media"}
</div>
<div className="max-w-lg space-y-2">
<p className="text-xl font-semibold tracking-[-0.03em] text-white sm:text-2xl">
{isChinese
? "这里会展示当前域名的视频演示"
: "This area shows the domain-specific product demo"}
</p>
<p className="text-sm leading-6 text-white/72 sm:text-[0.95rem]">
{isChinese
? "管理页可为不同域名配置不同链接。若链接不是可嵌入或直链格式,这里会保留为占位状态。"
: "The admin page can assign a different link per host. Unsupported links remain in placeholder mode until a valid embed or direct video URL is provided."}
</p>
</div>
</div>
) : null}
</div>
);
}

View File

@ -49,6 +49,7 @@ type OpenClawAssistantPaneProps = {
defaults: IntegrationDefaults;
initialQuestion?: string;
initialQuestionKey?: number;
autoSubmitInitialQuestion?: boolean;
variant?: "page" | "sidebar";
showConversation?: boolean;
emptyConversationHint?: string;
@ -98,7 +99,9 @@ function asRecord(value: unknown): Record<string, unknown> {
}
function stringValue(value: unknown): string | undefined {
return typeof value === "string" && value.trim().length > 0 ? value : undefined;
return typeof value === "string" && value.trim().length > 0
? value
: undefined;
}
function formatAssistantApiError(params: {
@ -132,6 +135,21 @@ function formatAssistantApiError(params: {
.join("\n");
}
function buildPairingRequiredSignature(
payload: AssistantApiErrorPayload,
): string | null {
if (payload.code !== "PAIRING_REQUIRED") {
return null;
}
const details = asRecord(payload.details);
const requestId = stringValue(details.requestId) ?? "";
const deviceId =
payload.deviceId?.trim() || stringValue(details.deviceId) || "";
const reason = stringValue(details.reason) ?? "";
return `${deviceId}::${requestId}::${reason}`;
}
function renderMarkdown(value: string): string {
return DOMPurify.sanitize(marked.parse(value) as string);
}
@ -262,6 +280,7 @@ export function OpenClawAssistantPane({
defaults,
initialQuestion,
initialQuestionKey,
autoSubmitInitialQuestion = true,
variant = "page",
showConversation = true,
emptyConversationHint,
@ -273,7 +292,9 @@ export function OpenClawAssistantPane({
const fileInputRef = useRef<HTMLInputElement | null>(null);
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const bootstrappedRef = useRef(false);
const lastInitialQuestionKeyRef = useRef<number | null>(null);
const lastPrefillQuestionKeyRef = useRef<number | null>(null);
const pendingAutoSubmitQuestionKeyRef = useRef<number | null>(null);
const lastPairingRequiredSignatureRef = useRef<string | null>(null);
const [connectionState, setConnectionState] =
useState<ConnectionState>("idle");
@ -301,7 +322,9 @@ export function OpenClawAssistantPane({
);
const applyDefaults = useOpenClawConsoleStore((state) => state.applyDefaults);
const openclawUrl = useOpenClawConsoleStore((state) => state.openclawUrl);
const openclawOrigin = useOpenClawConsoleStore((state) => state.openclawOrigin);
const openclawOrigin = useOpenClawConsoleStore(
(state) => state.openclawOrigin,
);
const openclawToken = useOpenClawConsoleStore((state) => state.openclawToken);
const vaultUrl = useOpenClawConsoleStore((state) => state.vaultUrl);
const vaultNamespace = useOpenClawConsoleStore(
@ -337,8 +360,10 @@ export function OpenClawAssistantPane({
const minimalPage = variant === "page";
const locale = isChinese ? "zh-CN" : "en-US";
const compactConnected = compact && connectionState === "ready";
const showMinimalAgentSelect = !minimalPage || agents.length > 1 || Boolean(selectedAgentId);
const showTopBar = !minimalPage || showMinimalAgentSelect || connectionState !== "ready";
const showMinimalAgentSelect =
!minimalPage || agents.length > 1 || Boolean(selectedAgentId);
const showTopBar =
!minimalPage || showMinimalAgentSelect || connectionState !== "ready";
const quickActions = useMemo(
() =>
@ -415,8 +440,16 @@ export function OpenClawAssistantPane({
"当前没有可用的 OpenClaw 地址。先到融合设置填写 gateway / vault / APISIX再回来启动 XWorkmate。",
"No OpenClaw endpoint is available yet. Configure gateway, vault, and APISIX first, then return to XWorkmate.",
),
openIntegrations: pickCopy(isChinese, "打开接口集成", "Open integrations"),
assistantTitle: pickCopy(isChinese, "XWorkmate 助手", "XWorkmate Assistant"),
openIntegrations: pickCopy(
isChinese,
"打开接口集成",
"Open integrations",
),
assistantTitle: pickCopy(
isChinese,
"XWorkmate 助手",
"XWorkmate Assistant",
),
assistantHint: pickCopy(
isChinese,
"侧栏模式与主页布局保持不变,消息会通过 OpenClaw gateway 进入 XWorkmate。你可以上传文件、贴图或直接截当前页给助手分析。",
@ -531,6 +564,29 @@ export function OpenClawAssistantPane({
streamingText,
]);
const presentAssistantError = useCallback(
(payload: AssistantApiErrorPayload, fallback: string) => {
const signature = buildPairingRequiredSignature(payload);
if (signature) {
if (lastPairingRequiredSignatureRef.current === signature) {
return;
}
lastPairingRequiredSignatureRef.current = signature;
} else {
lastPairingRequiredSignatureRef.current = null;
}
setErrorMessage(
formatAssistantApiError({
payload,
isChinese,
fallback,
}),
);
},
[isChinese],
);
const connectGateway = useCallback(
async (nextSessionKey?: string, nextAgentId?: string): Promise<void> => {
if (!openclawUrl.trim()) {
@ -568,13 +624,12 @@ export function OpenClawAssistantPane({
| AssistantApiErrorPayload;
if (!response.ok || "error" in payload) {
throw new Error(
formatAssistantApiError({
payload: payload as AssistantApiErrorPayload,
isChinese,
fallback: copy.bootstrapFailed,
}),
presentAssistantError(
payload as AssistantApiErrorPayload,
copy.bootstrapFailed,
);
setConnectionState("error");
return;
}
const data = payload as OpenClawBootstrapResponse;
@ -600,10 +655,10 @@ export function OpenClawAssistantPane({
copy.bootstrapFailed,
copy.connectFailed,
copy.serverMissing,
isChinese,
openclawToken,
openclawOrigin,
openclawUrl,
presentAssistantError,
vaultNamespace,
vaultSecretKey,
vaultSecretPath,
@ -683,12 +738,12 @@ export function OpenClawAssistantPane({
setStreamingText("");
setMessages((current) => [
...current,
{
id: randomId(),
role: "user",
text: rawPrompt || copy.attachedFallback,
timestampMs: Date.now(),
},
{
id: randomId(),
role: "user",
text: rawPrompt || copy.attachedFallback,
timestampMs: Date.now(),
},
]);
setComposerValue("");
@ -727,14 +782,14 @@ export function OpenClawAssistantPane({
if (!response.ok || !response.body) {
const payload = await response
.json()
.catch(() => ({ error: copy.sendFailed } as AssistantApiErrorPayload));
throw new Error(
formatAssistantApiError({
payload: payload as AssistantApiErrorPayload,
isChinese,
fallback: copy.sendFailed,
}),
.catch(
() => ({ error: copy.sendFailed }) as AssistantApiErrorPayload,
);
presentAssistantError(
payload as AssistantApiErrorPayload,
copy.sendFailed,
);
throw new Error(copy.sendFailed);
}
const reader = response.body.getReader();
@ -776,23 +831,24 @@ export function OpenClawAssistantPane({
}
if (event.type === "error") {
setErrorMessage(
formatAssistantApiError({
payload: {
error: event.message,
code: event.code,
details: event.details ?? null,
deviceId: event.deviceId,
},
isChinese,
fallback: copy.sendFailed,
}),
presentAssistantError(
{
error: event.message,
code: event.code,
details: event.details ?? null,
deviceId: event.deviceId,
},
copy.sendFailed,
);
}
}
}
} catch (error) {
setErrorMessage(error instanceof Error ? error.message : copy.sendFailed);
if (!(error instanceof Error && error.message === copy.sendFailed)) {
setErrorMessage(
error instanceof Error ? error.message : copy.sendFailed,
);
}
setStreamingText("");
} finally {
setAttachments([]);
@ -812,6 +868,7 @@ export function OpenClawAssistantPane({
openclawToken,
openclawOrigin,
openclawUrl,
presentAssistantError,
vaultNamespace,
vaultSecretKey,
vaultSecretPath,
@ -839,19 +896,52 @@ export function OpenClawAssistantPane({
}, [connectGateway, defaultsLoaded, openclawUrl]);
useEffect(() => {
if (!initialQuestion || connectionState !== "ready") {
if (!initialQuestion) {
return;
}
const resolvedKey = initialQuestionKey ?? 1;
if (lastInitialQuestionKeyRef.current === resolvedKey) {
if (lastPrefillQuestionKeyRef.current === resolvedKey) {
return;
}
lastInitialQuestionKeyRef.current = resolvedKey;
lastPrefillQuestionKeyRef.current = resolvedKey;
pendingAutoSubmitQuestionKeyRef.current = autoSubmitInitialQuestion
? resolvedKey
: null;
setComposerValue(initialQuestion);
requestAnimationFrame(() => {
textareaRef.current?.focus();
textareaRef.current?.setSelectionRange(
initialQuestion.length,
initialQuestion.length,
);
});
}, [autoSubmitInitialQuestion, initialQuestion, initialQuestionKey]);
useEffect(() => {
if (
!autoSubmitInitialQuestion ||
!initialQuestion ||
connectionState !== "ready"
) {
return;
}
const resolvedKey = initialQuestionKey ?? 1;
if (pendingAutoSubmitQuestionKeyRef.current !== resolvedKey) {
return;
}
pendingAutoSubmitQuestionKeyRef.current = null;
void sendMessage(initialQuestion);
}, [connectionState, initialQuestion, initialQuestionKey, sendMessage]);
}, [
autoSubmitInitialQuestion,
connectionState,
initialQuestion,
initialQuestionKey,
sendMessage,
]);
function handleTextareaKeyDown(event: KeyboardEvent<HTMLTextAreaElement>) {
if (event.key === "Enter" && !event.shiftKey) {
@ -895,105 +985,110 @@ export function OpenClawAssistantPane({
minimalPage ? "min-h-[52px]" : "",
)}
>
{!minimalPage ? (
<div className="inline-flex items-center gap-2 rounded-full border border-[color:var(--color-surface-border)] bg-[var(--color-surface-muted)] px-2.5 py-1 text-xs font-medium text-[var(--color-text-subtle)]">
<span
{!minimalPage ? (
<div className="inline-flex items-center gap-2 rounded-full border border-[color:var(--color-surface-border)] bg-[var(--color-surface-muted)] px-2.5 py-1 text-xs font-medium text-[var(--color-text-subtle)]">
<span
className={cn(
"h-2.5 w-2.5 rounded-full",
connectionState === "ready"
? "bg-emerald-500"
: connectionState === "connecting"
? "bg-amber-400"
: connectionState === "error"
? "bg-rose-500"
: "bg-[var(--color-text-subtle)]/40",
)}
/>
{healthBadge}
{!compactConnected ? (
<>
<span className="text-[var(--color-text-subtle)]/60">·</span>
{gatewayTokenSource === "env"
? copy.envToken
: gatewayTokenSource === "vault"
? copy.vaultToken
: gatewayTokenSource === "request"
? copy.sessionToken
: copy.noToken}
</>
) : null}
</div>
) : null}
{showMinimalAgentSelect ? (
<div
className={cn(
"h-2.5 w-2.5 rounded-full",
connectionState === "ready"
? "bg-emerald-500"
: connectionState === "connecting"
? "bg-amber-400"
: connectionState === "error"
? "bg-rose-500"
: "bg-[var(--color-text-subtle)]/40",
"min-w-[164px] flex-1",
minimalPage ? "max-w-xl" : "",
)}
/>
{healthBadge}
{!compactConnected ? (
<>
<span className="text-[var(--color-text-subtle)]/60">·</span>
{gatewayTokenSource === "env"
? copy.envToken
: gatewayTokenSource === "vault"
? copy.vaultToken
: gatewayTokenSource === "request"
? copy.sessionToken
: copy.noToken}
</>
) : null}
</div>
) : null}
{showMinimalAgentSelect ? (
<div className={cn("min-w-[164px] flex-1", minimalPage ? "max-w-xl" : "")}>
<select
value={selectedAgentId}
onChange={(event) => {
setSelectedAgentId(event.target.value);
setSelectedSessionKey("");
void connectGateway("", event.target.value);
}}
className="w-full rounded-full border border-[color:var(--color-surface-border)] bg-[var(--color-surface)] px-3 py-1.5 text-sm text-[var(--color-text)] outline-none transition focus:border-[color:var(--color-primary)]"
>
<option value="">{copy.mainAgent}</option>
{agents.map((agent) => (
<option key={agent.id} value={agent.id}>
{agent.emoji ? `${agent.emoji} ` : ""}
{agent.name}
</option>
))}
</select>
</div>
) : (
<div className="flex-1" />
)}
<select
value={selectedAgentId}
onChange={(event) => {
setSelectedAgentId(event.target.value);
setSelectedSessionKey("");
void connectGateway("", event.target.value);
}}
className="w-full rounded-full border border-[color:var(--color-surface-border)] bg-[var(--color-surface)] px-3 py-1.5 text-sm text-[var(--color-text)] outline-none transition focus:border-[color:var(--color-primary)]"
>
<option value="">{copy.mainAgent}</option>
{agents.map((agent) => (
<option key={agent.id} value={agent.id}>
{agent.emoji ? `${agent.emoji} ` : ""}
{agent.name}
</option>
))}
</select>
</div>
) : (
<div className="flex-1" />
)}
{minimalPage ? (
<div className="ml-auto inline-flex items-center gap-2 rounded-full border border-[color:var(--color-surface-border)] bg-[var(--color-primary-muted)] px-3 py-1.5 text-xs font-semibold text-[var(--color-heading)]">
<span
className={cn(
"h-2.5 w-2.5 rounded-full",
connectionState === "ready"
? "bg-emerald-500"
: connectionState === "connecting"
? "bg-amber-400"
: connectionState === "error"
? "bg-rose-500"
: "bg-[var(--color-text-subtle)]/40",
)}
/>
{healthBadge}
</div>
) : (
<>
<button
type="button"
onClick={() => {
void connectGateway();
}}
className="inline-flex items-center gap-2 rounded-full border border-[color:var(--color-surface-border)] px-3 py-1.5 text-xs font-semibold text-[var(--color-text)] transition hover:border-[color:var(--color-primary-border)] hover:bg-[var(--color-surface-muted)]"
title={copy.reconnect}
>
{connectionState === "connecting" ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<RefreshCw className="h-3.5 w-3.5" />
)}
{!compactConnected ? copy.reconnect : null}
</button>
{minimalPage ? (
<div className="ml-auto inline-flex items-center gap-2 rounded-full border border-[color:var(--color-surface-border)] bg-[var(--color-primary-muted)] px-3 py-1.5 text-xs font-semibold text-[var(--color-heading)]">
<span
className={cn(
"h-2.5 w-2.5 rounded-full",
connectionState === "ready"
? "bg-emerald-500"
: connectionState === "connecting"
? "bg-amber-400"
: connectionState === "error"
? "bg-rose-500"
: "bg-[var(--color-text-subtle)]/40",
)}
/>
{healthBadge}
</div>
) : (
<>
<button
type="button"
onClick={() => {
void connectGateway();
}}
className="inline-flex items-center gap-2 rounded-full border border-[color:var(--color-surface-border)] px-3 py-1.5 text-xs font-semibold text-[var(--color-text)] transition hover:border-[color:var(--color-primary-border)] hover:bg-[var(--color-surface-muted)]"
title={copy.reconnect}
>
{connectionState === "connecting" ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<RefreshCw className="h-3.5 w-3.5" />
)}
{!compactConnected ? copy.reconnect : null}
</button>
<button
type="button"
onClick={() => router.push("/panel/api")}
className="inline-flex items-center gap-2 rounded-full border border-[color:var(--color-primary-border)] bg-[var(--color-primary-muted)] px-3 py-1.5 text-xs font-semibold text-[var(--color-primary)] transition hover:opacity-90"
title={copy.integrations}
>
<Settings2 className="h-3.5 w-3.5" />
{!compactConnected ? copy.integrations : null}
</button>
</>
)}
<button
type="button"
onClick={() => router.push("/panel/api")}
className="inline-flex items-center gap-2 rounded-full border border-[color:var(--color-primary-border)] bg-[var(--color-primary-muted)] px-3 py-1.5 text-xs font-semibold text-[var(--color-primary)] transition hover:opacity-90"
title={copy.integrations}
>
<Settings2 className="h-3.5 w-3.5" />
{!compactConnected ? copy.integrations : null}
</button>
</>
)}
</div>
) : null}

View File

@ -1,44 +0,0 @@
export type HeroVideoMedia = {
posterUrl?: string;
videoUrl?: string;
title: {
zh: string;
en: string;
};
description: {
zh: string;
en: string;
};
statusLabel: {
zh: string;
en: string;
};
durationLabel: string;
chapters: {
zh: string;
en: string;
}[];
};
export const heroVideoMedia: HeroVideoMedia = {
posterUrl: "",
videoUrl: "",
title: {
zh: "用一段视频解释从灵感到上线的完整路径",
en: "Show the full path from idea to launch in one video",
},
description: {
zh: "建议后续放 60 到 120 秒的产品导览、集成配置流程,或真实部署 walkthrough。",
en: "Best used for a 60-120 second product tour, integration setup flow, or real deployment walkthrough.",
},
statusLabel: {
zh: "视频待接入",
en: "Video pending",
},
durationLabel: "00:00 / 02:18",
chapters: [
{ zh: "开场介绍", en: "Intro" },
{ zh: "集成配置", en: "Setup" },
{ zh: "上线演示", en: "Launch" },
],
};

View File

@ -0,0 +1,191 @@
export type HomepageVideoEntry = {
domain?: string;
videoUrl: string;
posterUrl?: string;
};
export type HomepageVideoSettingsResponse = {
defaultEntry: HomepageVideoEntry;
overrides: HomepageVideoEntry[];
};
export type ResolvedHomepageVideoResponse = {
resolved: HomepageVideoEntry;
};
export type HomepageVideoPresentation =
| {
kind: "embed";
provider: "youtube" | "bilibili";
src: string;
posterUrl?: string;
videoUrl: string;
}
| {
kind: "direct";
provider: "direct";
src: string;
posterUrl?: string;
videoUrl: string;
}
| {
kind: "empty";
provider: "none";
src: "";
posterUrl?: string;
videoUrl: string;
};
export const DEFAULT_HOMEPAGE_VIDEO_SETTINGS: HomepageVideoSettingsResponse = {
defaultEntry: {
videoUrl: "https://www.youtube.com/watch?v=UW6DY6HQnmo",
posterUrl: "",
},
overrides: [
{
domain: "cn-www.svc.plus",
videoUrl:
"https://www.bilibili.com/video/BV12DwazxEkL/?spm_id_from=333.1387.homepage.video_card.click&vd_source=e14d146f9a815c7d11e1a1fc23565ffd",
posterUrl: "",
},
],
};
function trimValue(value?: string): string {
return value?.trim() ?? "";
}
export function normalizeHomepageVideoEntry(
entry?: HomepageVideoEntry,
): HomepageVideoEntry {
return {
domain: trimValue(entry?.domain),
videoUrl: trimValue(entry?.videoUrl),
posterUrl: trimValue(entry?.posterUrl),
};
}
function tryParseUrl(raw: string): URL | null {
const trimmed = trimValue(raw);
if (!trimmed) {
return null;
}
try {
return new URL(trimmed);
} catch {
return null;
}
}
function resolveYoutubeEmbed(url: URL): string | undefined {
const host = url.hostname.toLowerCase();
if (host === "youtu.be") {
const videoId = url.pathname.replace(/^\/+/, "").split("/")[0];
return videoId ? `https://www.youtube.com/embed/${videoId}` : undefined;
}
if (host.endsWith("youtube.com")) {
if (url.pathname === "/watch") {
const videoId = trimValue(url.searchParams.get("v") ?? "");
return videoId ? `https://www.youtube.com/embed/${videoId}` : undefined;
}
const segments = url.pathname.split("/").filter(Boolean);
const videoId = segments.at(-1);
if (segments[0] === "embed" && videoId) {
return `https://www.youtube.com/embed/${videoId}`;
}
if ((segments[0] === "shorts" || segments[0] === "live") && videoId) {
return `https://www.youtube.com/embed/${videoId}`;
}
}
return undefined;
}
function resolveBilibiliEmbed(url: URL): string | undefined {
const host = url.hostname.toLowerCase();
if (!host.endsWith("bilibili.com")) {
return undefined;
}
const match = url.pathname.match(/\/video\/(BV[0-9A-Za-z]+)/i);
const bvid = match?.[1];
if (!bvid) {
return undefined;
}
const page = trimValue(url.searchParams.get("p") ?? "1") || "1";
return `https://player.bilibili.com/player.html?bvid=${encodeURIComponent(bvid)}&page=${encodeURIComponent(page)}`;
}
function isDirectVideoUrl(url: URL): boolean {
return /\.(mp4|webm|ogg|mov|m4v)(\?|#|$)/i.test(url.pathname);
}
export function resolveHomepageVideoPresentation(
entry?: HomepageVideoEntry,
): HomepageVideoPresentation {
const normalized = normalizeHomepageVideoEntry(entry);
if (!normalized.videoUrl) {
return {
kind: "empty",
provider: "none",
src: "",
posterUrl: normalized.posterUrl,
videoUrl: "",
};
}
const parsed = tryParseUrl(normalized.videoUrl);
if (!parsed) {
return {
kind: "empty",
provider: "none",
src: "",
posterUrl: normalized.posterUrl,
videoUrl: "",
};
}
const youtubeEmbed = resolveYoutubeEmbed(parsed);
if (youtubeEmbed) {
return {
kind: "embed",
provider: "youtube",
src: youtubeEmbed,
posterUrl: normalized.posterUrl,
videoUrl: normalized.videoUrl,
};
}
const bilibiliEmbed = resolveBilibiliEmbed(parsed);
if (bilibiliEmbed) {
return {
kind: "embed",
provider: "bilibili",
src: bilibiliEmbed,
posterUrl: normalized.posterUrl,
videoUrl: normalized.videoUrl,
};
}
if (isDirectVideoUrl(parsed)) {
return {
kind: "direct",
provider: "direct",
src: normalized.videoUrl,
posterUrl: normalized.posterUrl,
videoUrl: normalized.videoUrl,
};
}
return {
kind: "empty",
provider: "none",
src: "",
posterUrl: normalized.posterUrl,
videoUrl: normalized.videoUrl,
};
}

View File

@ -0,0 +1,258 @@
"use client";
import { useEffect, useState } from "react";
import { Film, Plus, Trash2 } from "lucide-react";
import Card from "../../components/Card";
import type {
HomepageVideoEntry,
HomepageVideoSettingsResponse,
} from "@/lib/home/homepageVideo";
type HomepageVideoSettingsPanelProps = {
settings?: HomepageVideoSettingsResponse;
isLoading?: boolean;
isSaving?: boolean;
canEdit: boolean;
statusMessage?: string;
errorMessage?: string;
onSave: (payload: HomepageVideoSettingsResponse) => Promise<void>;
};
function normalizeEntry(entry?: HomepageVideoEntry): HomepageVideoEntry {
return {
domain: entry?.domain?.trim() ?? "",
videoUrl: entry?.videoUrl?.trim() ?? "",
posterUrl: entry?.posterUrl?.trim() ?? "",
};
}
export default function HomepageVideoSettingsPanel({
settings,
isLoading = false,
isSaving = false,
canEdit,
statusMessage,
errorMessage,
onSave,
}: HomepageVideoSettingsPanelProps) {
const [defaultEntry, setDefaultEntry] = useState<HomepageVideoEntry>({
videoUrl: "",
posterUrl: "",
});
const [overrides, setOverrides] = useState<HomepageVideoEntry[]>([]);
useEffect(() => {
if (!settings) {
return;
}
setDefaultEntry(normalizeEntry(settings.defaultEntry));
setOverrides(settings.overrides.map((item) => normalizeEntry(item)));
}, [settings]);
return (
<Card>
<div className="space-y-5">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="space-y-2">
<div className="inline-flex items-center gap-2 rounded-full border border-slate-200 bg-slate-50 px-3 py-1 text-xs font-semibold text-slate-600">
<Film className="h-3.5 w-3.5" />
</div>
<div>
<h2 className="text-xl font-semibold text-gray-900">
</h2>
<p className="mt-1 text-sm leading-6 text-gray-600">
</p>
</div>
</div>
<button
type="button"
disabled={!canEdit || isSaving}
onClick={() => {
void onSave({
defaultEntry: normalizeEntry(defaultEntry),
overrides: overrides.map((item) => normalizeEntry(item)),
});
}}
className="inline-flex h-10 items-center justify-center rounded-xl bg-slate-950 px-4 text-sm font-semibold text-white transition hover:bg-slate-800 disabled:cursor-not-allowed disabled:opacity-60"
>
{isSaving ? "保存中..." : canEdit ? "保存视频配置" : "仅可查看"}
</button>
</div>
{statusMessage ? (
<div className="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-700">
{statusMessage}
</div>
) : null}
{errorMessage ? (
<div className="rounded-xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">
{errorMessage}
</div>
) : null}
<div className="rounded-2xl border border-slate-200 bg-slate-50/80 p-4">
<div className="mb-3 flex items-center justify-between">
<h3 className="text-sm font-semibold text-slate-900"></h3>
<span className="text-xs text-slate-500"></span>
</div>
<div className="grid gap-3 md:grid-cols-2">
<label className="space-y-1.5">
<span className="text-xs font-medium text-slate-600">
</span>
<input
value={defaultEntry.videoUrl ?? ""}
onChange={(event) =>
setDefaultEntry((current) => ({
...current,
videoUrl: event.target.value,
}))
}
disabled={!canEdit || isLoading}
placeholder="https://www.youtube.com/watch?v=..."
className="w-full rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm text-slate-900 outline-none transition focus:border-slate-400 disabled:bg-slate-100"
/>
</label>
<label className="space-y-1.5">
<span className="text-xs font-medium text-slate-600">
Poster
</span>
<input
value={defaultEntry.posterUrl ?? ""}
onChange={(event) =>
setDefaultEntry((current) => ({
...current,
posterUrl: event.target.value,
}))
}
disabled={!canEdit || isLoading}
placeholder="https://cdn.example.com/poster.jpg"
className="w-full rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm text-slate-900 outline-none transition focus:border-slate-400 disabled:bg-slate-100"
/>
</label>
</div>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-semibold text-slate-900"></h3>
<p className="mt-1 text-xs text-slate-500">
`cn-www.svc.plus` 使 Bilibili使
</p>
</div>
<button
type="button"
disabled={!canEdit || isSaving}
onClick={() =>
setOverrides((current) => [
...current,
{ domain: "", videoUrl: "", posterUrl: "" },
])
}
className="inline-flex items-center gap-2 rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm font-medium text-slate-700 transition hover:border-slate-300 hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-60"
>
<Plus className="h-4 w-4" />
</button>
</div>
{overrides.length === 0 ? (
<div className="rounded-2xl border border-dashed border-slate-200 bg-slate-50/70 px-4 py-6 text-sm text-slate-500">
使
</div>
) : null}
{overrides.map((item, index) => (
<div
key={`${item.domain}-${index}`}
className="rounded-2xl border border-slate-200 bg-white p-4"
>
<div className="grid gap-3 lg:grid-cols-[0.9fr_1.2fr_1.2fr_auto]">
<label className="space-y-1.5">
<span className="text-xs font-medium text-slate-600">
</span>
<input
value={item.domain ?? ""}
onChange={(event) =>
setOverrides((current) =>
current.map((entry, entryIndex) =>
entryIndex === index
? { ...entry, domain: event.target.value }
: entry,
),
)
}
disabled={!canEdit || isLoading}
placeholder="cn-www.svc.plus"
className="w-full rounded-xl border border-slate-200 bg-slate-50 px-3 py-2 text-sm text-slate-900 outline-none transition focus:border-slate-400 disabled:bg-slate-100"
/>
</label>
<label className="space-y-1.5">
<span className="text-xs font-medium text-slate-600">
</span>
<input
value={item.videoUrl}
onChange={(event) =>
setOverrides((current) =>
current.map((entry, entryIndex) =>
entryIndex === index
? { ...entry, videoUrl: event.target.value }
: entry,
),
)
}
disabled={!canEdit || isLoading}
placeholder="https://www.bilibili.com/video/BV..."
className="w-full rounded-xl border border-slate-200 bg-slate-50 px-3 py-2 text-sm text-slate-900 outline-none transition focus:border-slate-400 disabled:bg-slate-100"
/>
</label>
<label className="space-y-1.5">
<span className="text-xs font-medium text-slate-600">
Poster
</span>
<input
value={item.posterUrl ?? ""}
onChange={(event) =>
setOverrides((current) =>
current.map((entry, entryIndex) =>
entryIndex === index
? { ...entry, posterUrl: event.target.value }
: entry,
),
)
}
disabled={!canEdit || isLoading}
placeholder="可选"
className="w-full rounded-xl border border-slate-200 bg-slate-50 px-3 py-2 text-sm text-slate-900 outline-none transition focus:border-slate-400 disabled:bg-slate-100"
/>
</label>
<div className="flex items-end">
<button
type="button"
disabled={!canEdit || isSaving}
onClick={() =>
setOverrides((current) =>
current.filter((_, entryIndex) => entryIndex !== index),
)
}
className="inline-flex h-10 w-10 items-center justify-center rounded-xl border border-rose-200 bg-rose-50 text-rose-600 transition hover:bg-rose-100 disabled:cursor-not-allowed disabled:opacity-60"
aria-label="Remove override"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
</div>
))}
</div>
</div>
</Card>
);
}

View File

@ -19,9 +19,11 @@ import UserGroupManagement, {
} from "../management/components/UserGroupManagement";
import SandboxNodeBindingPanel from "../management/components/SandboxNodeBindingPanel";
import RootAssumeSandboxPanel from "../management/components/RootAssumeSandboxPanel";
import HomepageVideoSettingsPanel from "../management/components/HomepageVideoSettingsPanel";
import { EmailBlacklist } from "../management/components/EmailBlacklist";
import Breadcrumbs from "@/app/panel/components/Breadcrumbs";
import { resolveAccess } from "@lib/accessControl";
import type { HomepageVideoSettingsResponse } from "@/lib/home/homepageVideo";
import { useUserStore } from "@lib/userStore";
import { useLanguage } from "@i18n/LanguageProvider";
import { translations } from "@i18n/translations";
@ -114,6 +116,13 @@ export default function UserCenterManagementRoute() {
new Set(),
);
const [isBlacklistOpen, setIsBlacklistOpen] = useState(false);
const [homepageVideoSaving, setHomepageVideoSaving] = useState(false);
const [homepageVideoStatus, setHomepageVideoStatus] = useState<
string | undefined
>();
const [homepageVideoError, setHomepageVideoError] = useState<
string | undefined
>();
const metricsSWR = useSWR<UserMetricsResponse>(
canAccess ? "/api/admin/users/metrics" : null,
@ -136,6 +145,13 @@ export default function UserCenterManagementRoute() {
revalidateOnFocus: false,
},
);
const homepageVideoSWR = useSWR<HomepageVideoSettingsResponse>(
canAccess ? "/api/admin/homepage-video" : null,
jsonFetcher,
{
revalidateOnFocus: false,
},
);
useEffect(() => {
if (settingsSWR.data?.matrix) {
@ -389,6 +405,51 @@ export default function UserCenterManagementRoute() {
[canCreateCustomUser, usersSWR],
);
const handleSaveHomepageVideo = useCallback(
async (payload: HomepageVideoSettingsResponse) => {
setHomepageVideoSaving(true);
setHomepageVideoStatus(undefined);
setHomepageVideoError(undefined);
try {
const response = await fetch("/api/admin/homepage-video", {
method: "PUT",
credentials: "include",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
const responsePayload = (await response.json().catch(() => ({}))) as
| HomepageVideoSettingsResponse
| ApiError;
if (!response.ok) {
throw new Error(
(responsePayload as ApiError).error ??
(responsePayload as ApiError).message ??
"保存失败",
);
}
homepageVideoSWR.mutate(
responsePayload as HomepageVideoSettingsResponse,
{ revalidate: false },
);
setHomepageVideoStatus("首页视频配置已保存");
} catch (error) {
setHomepageVideoError(
error instanceof Error ? error.message : "保存失败",
);
} finally {
setHomepageVideoSaving(false);
}
},
[homepageVideoSWR],
);
const matrixPending = matrixSaving || isUserLoading;
const metricsLoading = metricsSWR.isLoading;
const settingsLoading = settingsSWR.isLoading;
@ -434,6 +495,15 @@ export default function UserCenterManagementRoute() {
onSave={handleSaveMatrix}
canEdit={canEditPermissions}
/>
<HomepageVideoSettingsPanel
settings={homepageVideoSWR.data}
isLoading={homepageVideoSWR.isLoading}
isSaving={homepageVideoSaving}
canEdit={canEditPermissions}
statusMessage={homepageVideoStatus}
errorMessage={homepageVideoError}
onSave={handleSaveHomepageVideo}
/>
<UserGroupManagement
users={usersSWR.data}
isLoading={usersLoading}