Refactor homepage into prompt-first workspace
This commit is contained in:
parent
00023b808b
commit
9e452ca464
@ -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}
|
||||
|
||||
117
src/app/api/admin/homepage-video/route.ts
Normal file
117
src/app/api/admin/homepage-video/route.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
40
src/app/api/homepage-video/route.ts
Normal file
40
src/app/api/homepage-video/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
461
src/app/page.tsx
461
src/app/page.tsx
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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}
|
||||
|
||||
|
||||
@ -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" },
|
||||
],
|
||||
};
|
||||
191
src/lib/home/homepageVideo.ts
Normal file
191
src/lib/home/homepageVideo.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user