Initial ecommerce standalone package
This commit is contained in:
@@ -0,0 +1,110 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { keyServerClient } from "../api/keyServerClient";
|
||||
|
||||
export interface ClientErrorItem {
|
||||
id: number;
|
||||
message: string;
|
||||
stack?: string;
|
||||
source: string;
|
||||
url: string;
|
||||
user_agent?: string;
|
||||
user_id?: number;
|
||||
count: number;
|
||||
first_seen: string;
|
||||
last_seen: string;
|
||||
}
|
||||
|
||||
const STORAGE_KEY = "omniai:admin-monitor-open";
|
||||
const POLL_INTERVAL = 30000;
|
||||
|
||||
function formatTime(iso: string) {
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||
}
|
||||
|
||||
function AdminMonitor() {
|
||||
const [open, setOpen] = useState(() => {
|
||||
try { return sessionStorage.getItem(STORAGE_KEY) === "1"; } catch { return false; }
|
||||
});
|
||||
const [errors, setErrors] = useState<ClientErrorItem[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval>>();
|
||||
|
||||
const fetchErrors = useCallback(async (p = 1) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await keyServerClient.getClientErrors(p);
|
||||
setErrors(data.items);
|
||||
setTotal(data.total);
|
||||
setPage(p);
|
||||
} catch { /* silent */ }
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
void fetchErrors(1);
|
||||
intervalRef.current = setInterval(() => fetchErrors(1), POLL_INTERVAL);
|
||||
return () => clearInterval(intervalRef.current);
|
||||
}, [open, fetchErrors]);
|
||||
|
||||
useEffect(() => {
|
||||
try { sessionStorage.setItem(STORAGE_KEY, open ? "1" : "0"); } catch { /* */ }
|
||||
}, [open]);
|
||||
|
||||
const maxPage = Math.max(1, Math.ceil(total / 50));
|
||||
|
||||
if (!open) {
|
||||
return (
|
||||
<button type="button" className="admin-monitor-trigger" onClick={() => setOpen(true)} title="错误监控">
|
||||
<span className="admin-monitor-trigger__dot" aria-hidden="true" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="admin-monitor" role="dialog" aria-label="客户端错误监控">
|
||||
<header className="admin-monitor__header">
|
||||
<strong>客户端错误 ({total})</strong>
|
||||
<div className="admin-monitor__actions">
|
||||
<button type="button" onClick={() => void fetchErrors(1)} disabled={loading}>
|
||||
{loading ? "刷新中..." : "刷新"}
|
||||
</button>
|
||||
<button type="button" onClick={() => setOpen(false)}>关闭</button>
|
||||
</div>
|
||||
</header>
|
||||
<section className="admin-monitor__list">
|
||||
{errors.length === 0 ? (
|
||||
<div className="admin-monitor__empty">暂无错误</div>
|
||||
) : (
|
||||
errors.map((err) => (
|
||||
<details key={err.id} className="admin-monitor__item">
|
||||
<summary>
|
||||
<span className="admin-monitor__source">{err.source}</span>
|
||||
<span className="admin-monitor__msg">{err.message.slice(0, 120)}</span>
|
||||
<span className="admin-monitor__count">{err.count}</span>
|
||||
<time>{formatTime(err.last_seen)}</time>
|
||||
</summary>
|
||||
<div className="admin-monitor__detail">
|
||||
<div><b>URL:</b> {err.url}</div>
|
||||
<div><b>User:</b> {err.user_id || "匿名"}</div>
|
||||
{err.stack ? <pre>{err.stack.slice(0, 1000)}</pre> : null}
|
||||
</div>
|
||||
</details>
|
||||
))
|
||||
)}
|
||||
</section>
|
||||
{maxPage > 1 ? (
|
||||
<footer className="admin-monitor__pager">
|
||||
<button type="button" disabled={page <= 1} onClick={() => fetchErrors(page - 1)}>上一页</button>
|
||||
<span>{page} / {maxPage}</span>
|
||||
<button type="button" disabled={page >= maxPage} onClick={() => fetchErrors(page + 1)}>下一页</button>
|
||||
</footer>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AdminMonitor;
|
||||
@@ -0,0 +1,54 @@
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
|
||||
interface AnimatedPanelProps {
|
||||
open: boolean;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
/** Duration in ms for the exit animation before unmounting. */
|
||||
exitDuration?: number;
|
||||
}
|
||||
|
||||
export function AnimatedPanel({ open, children, className, exitDuration = 140 }: AnimatedPanelProps) {
|
||||
const [mounted, setMounted] = useState(open);
|
||||
const [visible, setVisible] = useState(open);
|
||||
const timerRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
if (timerRef.current) {
|
||||
window.clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
setMounted(true);
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
setVisible(true);
|
||||
});
|
||||
});
|
||||
} else {
|
||||
setVisible(false);
|
||||
timerRef.current = window.setTimeout(() => {
|
||||
setMounted(false);
|
||||
timerRef.current = null;
|
||||
}, exitDuration);
|
||||
}
|
||||
}, [open, exitDuration]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timerRef.current) {
|
||||
window.clearTimeout(timerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${className ?? ""} animated-panel${visible ? " is-visible" : ""}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,541 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { publicConfigClient, type WebPublicConfig } from "../api/publicConfigClient";
|
||||
import { toast } from "./toast/toastStore";
|
||||
import type { ServerConnectionHealth } from "../api/serverConnection";
|
||||
import { ossAssets } from "../data/ossAssets";
|
||||
import { canManageCommunityCases, canReviewCommunity } from "../features/community-review/communityPermissions";
|
||||
import type { WebNavItem, WebNotification, WebUsageSummary, WebUserSession, WebViewKey } from "../types";
|
||||
import NotificationCenter from "./NotificationCenter";
|
||||
import BetaApplicationModal from "./BetaApplicationModal";
|
||||
import { AnimatedPanel } from "./AnimatedPanel";
|
||||
import AdminMonitor from "./AdminMonitor";
|
||||
import CookieConsentBanner from "./CookieConsentBanner";
|
||||
import { loadRechargeModal, type RechargeModalComponent } from "./RechargeModal/loadRechargeModal";
|
||||
import { ShellIcon } from "./ShellIcon";
|
||||
import { loadDarkGreenTheme } from "../styles/loadDarkGreenTheme";
|
||||
|
||||
interface AppShellProps {
|
||||
activeView: WebViewKey;
|
||||
navItems: WebNavItem[];
|
||||
session: WebUserSession | null;
|
||||
usage: WebUsageSummary;
|
||||
notifications: WebNotification[];
|
||||
backendHealth: ServerConnectionHealth;
|
||||
workspaceExpanded: boolean;
|
||||
onSelectView: (view: WebViewKey) => void;
|
||||
onLogout: () => void;
|
||||
onOpenLogin: () => void;
|
||||
onMarkNotificationRead?: (id: string, isRead?: boolean) => void;
|
||||
onMarkAllNotificationsRead?: () => void;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const BRAND_LOGO_URL = ossAssets.brand.logo;
|
||||
const TOOL_SURFACE_VIEW_SET = new Set<WebViewKey>([
|
||||
"workbench",
|
||||
"canvas",
|
||||
"more",
|
||||
"scriptTokens",
|
||||
"tokenUsage",
|
||||
"ecommerceTemplates",
|
||||
"sizeTemplate",
|
||||
"imageWorkbench",
|
||||
"resolutionUpscale",
|
||||
"digitalHuman",
|
||||
"dialogGenerator",
|
||||
"avatarConsole",
|
||||
"characterMix",
|
||||
] as WebViewKey[]);
|
||||
const PRIMARY_NAV_ORDER: WebViewKey[] = [
|
||||
"workbench",
|
||||
"ecommerce",
|
||||
"sizeTemplate",
|
||||
"canvas",
|
||||
"scriptTokens",
|
||||
"tokenUsage",
|
||||
"more",
|
||||
"assets",
|
||||
"community",
|
||||
];
|
||||
|
||||
function formatBalance(cents: number): string {
|
||||
const value = Math.max(0, cents) / 100;
|
||||
return `${value.toFixed(2)} 积分`;
|
||||
}
|
||||
|
||||
function canReviewBetaApplications(session: WebUserSession | null): boolean {
|
||||
const role = String(session?.user.role || "").trim().toLowerCase();
|
||||
const username = String(session?.user.username || "").trim().toLowerCase();
|
||||
return role === "admin" || username === "xqy1912";
|
||||
}
|
||||
|
||||
function AppShell({
|
||||
activeView,
|
||||
navItems,
|
||||
session,
|
||||
usage,
|
||||
notifications,
|
||||
backendHealth,
|
||||
workspaceExpanded,
|
||||
onSelectView,
|
||||
onLogout,
|
||||
onOpenLogin,
|
||||
onMarkNotificationRead,
|
||||
onMarkAllNotificationsRead,
|
||||
children,
|
||||
}: AppShellProps) {
|
||||
const activePackage = session?.user.activePackages?.[0];
|
||||
const profileRef = useRef<HTMLDivElement>(null);
|
||||
const submenuHideTimerRef = useRef<number | null>(null);
|
||||
const [profileOpen, setProfileOpen] = useState(false);
|
||||
const [rechargeOpen, setRechargeOpen] = useState(false);
|
||||
const [RechargeModal, setRechargeModal] = useState<RechargeModalComponent | null>(null);
|
||||
const [infoOpen, setInfoOpen] = useState(false);
|
||||
const [betaOpen, setBetaOpen] = useState(false);
|
||||
const infoRef = useRef<HTMLDivElement>(null);
|
||||
const [openSubmenuKey, setOpenSubmenuKey] = useState<WebViewKey | null>(null);
|
||||
const [publicConfig, setPublicConfig] = useState<WebPublicConfig>({});
|
||||
const prevActiveViewRef = useRef<WebViewKey>(activeView);
|
||||
const [navJustActivated, setNavJustActivated] = useState<WebViewKey | null>(null);
|
||||
const isAuthView = activeView === "login";
|
||||
const isImmersiveView = activeView === "agent" || activeView === "avatarConsole";
|
||||
const showFloatingNav = !isAuthView && !isImmersiveView && activeView !== "home";
|
||||
const showPageScrollActions = showFloatingNav && !TOOL_SURFACE_VIEW_SET.has(activeView);
|
||||
|
||||
const visibleNavItems = useMemo(
|
||||
() => {
|
||||
const navItemByKey = new Map(navItems.map((item) => [item.key, item]));
|
||||
return PRIMARY_NAV_ORDER
|
||||
.map((key) => navItemByKey.get(key))
|
||||
.filter((item): item is WebNavItem => Boolean(item));
|
||||
},
|
||||
[navItems],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeView !== prevActiveViewRef.current) {
|
||||
setNavJustActivated(activeView);
|
||||
prevActiveViewRef.current = activeView;
|
||||
const timer = window.setTimeout(() => setNavJustActivated(null), 320);
|
||||
return () => window.clearTimeout(timer);
|
||||
}
|
||||
}, [activeView]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof document === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
void loadDarkGreenTheme();
|
||||
document.documentElement.dataset.theme = "dark";
|
||||
document.documentElement.dataset.uiTheme = "dark-green";
|
||||
document.documentElement.style.colorScheme = "dark";
|
||||
|
||||
const metaThemeColor = document.querySelector<HTMLMetaElement>("meta[name='theme-color']");
|
||||
if (metaThemeColor) {
|
||||
metaThemeColor.content = "#0d0d0f";
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
publicConfigClient
|
||||
.get()
|
||||
.then((config) => {
|
||||
if (!cancelled) setPublicConfig(config);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setPublicConfig({});
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!profileOpen) return;
|
||||
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
if (!profileRef.current?.contains(event.target as Node)) {
|
||||
setProfileOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("pointerdown", handlePointerDown);
|
||||
return () => document.removeEventListener("pointerdown", handlePointerDown);
|
||||
}, [profileOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!infoOpen) return;
|
||||
const handleInfoOutside = (event: PointerEvent) => {
|
||||
if (!infoRef.current?.contains(event.target as Node)) {
|
||||
setInfoOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("pointerdown", handleInfoOutside);
|
||||
return () => document.removeEventListener("pointerdown", handleInfoOutside);
|
||||
}, [infoOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!session) {
|
||||
setProfileOpen(false);
|
||||
}
|
||||
}, [session]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (submenuHideTimerRef.current) {
|
||||
window.clearTimeout(submenuHideTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!rechargeOpen || RechargeModal) return;
|
||||
|
||||
let cancelled = false;
|
||||
void loadRechargeModal().then((component) => {
|
||||
if (!cancelled) {
|
||||
setRechargeModal(() => component);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [RechargeModal, rechargeOpen]);
|
||||
|
||||
const showSubmenu = (key: WebViewKey) => {
|
||||
if (submenuHideTimerRef.current) {
|
||||
window.clearTimeout(submenuHideTimerRef.current);
|
||||
submenuHideTimerRef.current = null;
|
||||
}
|
||||
setOpenSubmenuKey(key);
|
||||
};
|
||||
|
||||
const scheduleHideSubmenu = () => {
|
||||
if (submenuHideTimerRef.current) {
|
||||
window.clearTimeout(submenuHideTimerRef.current);
|
||||
}
|
||||
submenuHideTimerRef.current = window.setTimeout(() => {
|
||||
setOpenSubmenuKey(null);
|
||||
submenuHideTimerRef.current = null;
|
||||
}, 1500);
|
||||
};
|
||||
|
||||
const scrollActivePage = (direction: "top" | "bottom") => {
|
||||
if (typeof document === "undefined") return;
|
||||
|
||||
const targets = [
|
||||
...Array.from(document.querySelectorAll<HTMLElement>(".web-shell__page")),
|
||||
...Array.from(
|
||||
document.querySelectorAll<HTMLElement>(
|
||||
".workbench-landing-page, .ecommerce-landing-page, .workspace-page-shell__content, .community-page",
|
||||
),
|
||||
),
|
||||
document.scrollingElement as HTMLElement | null,
|
||||
].filter((target): target is HTMLElement => Boolean(target));
|
||||
|
||||
targets.forEach((target) => {
|
||||
const top = direction === "top" ? 0 : target.scrollHeight;
|
||||
target.scrollTo({ top, behavior: "smooth" });
|
||||
});
|
||||
};
|
||||
|
||||
const displayName = session?.user.displayName || session?.user.username || "预览创作者";
|
||||
const avatarLabel = displayName.trim().slice(0, 1).toUpperCase() || "创";
|
||||
const avatarUrl = session?.user.avatarUrl || null;
|
||||
const isEnterpriseAccount = Boolean(session?.user.enterpriseId || session?.user.accountType === "enterprise");
|
||||
const displayedBalanceCents =
|
||||
session && isEnterpriseAccount
|
||||
? (usage.enterpriseBalanceCents ?? session.user.enterpriseBalanceCents ?? usage.balanceCents)
|
||||
: usage.balanceCents;
|
||||
const displayedBalanceLabel = session ? formatBalance(displayedBalanceCents) : "0 积分";
|
||||
const showCommunityReview = canReviewCommunity(session);
|
||||
const showCommunityCaseAdd = canManageCommunityCases(session);
|
||||
const showBetaApplicationReview = canReviewBetaApplications(session);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`web-shell event-strip-hidden${
|
||||
isAuthView ? " is-auth-view" : ""
|
||||
}${isImmersiveView ? " is-immersive-view" : ""}`}
|
||||
data-theme="dark"
|
||||
data-ui-theme="dark-green"
|
||||
data-view={activeView}
|
||||
>
|
||||
<div className="web-shell__stage">
|
||||
{showFloatingNav ? (
|
||||
<aside
|
||||
className={`floating-nav${workspaceExpanded ? " is-expanded" : " is-browse"}`}
|
||||
aria-label="网页端功能导航"
|
||||
>
|
||||
{visibleNavItems.map((item, index) => {
|
||||
const childActive = item.children?.some((child) => child.key === activeView) ?? false;
|
||||
const isActive = activeView === item.key || childActive;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.key}
|
||||
className={`floating-nav__item${item.children?.length ? " has-children" : ""}${
|
||||
isActive ? " is-active" : ""
|
||||
}${openSubmenuKey === item.key ? " is-submenu-open" : ""}`}
|
||||
onMouseEnter={item.children?.length ? () => showSubmenu(item.key) : undefined}
|
||||
onMouseLeave={item.children?.length ? scheduleHideSubmenu : undefined}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={`floating-nav__button${isActive ? " is-active" : ""}${
|
||||
navJustActivated === item.key ? " nav-just-activated" : ""
|
||||
}${workspaceExpanded && index === 3 ? " has-divider" : ""}`}
|
||||
title={`${item.label} / ${item.hint}`}
|
||||
aria-label={item.label}
|
||||
onClick={() => onSelectView(item.children?.[0]?.key ?? item.key)}
|
||||
>
|
||||
{item.icon}
|
||||
<span className="floating-nav__label">{item.label}</span>
|
||||
</button>
|
||||
{item.children?.length ? (
|
||||
<div className="floating-nav__submenu" aria-label={`${item.label} 子导航`}>
|
||||
{item.children.map((child) => (
|
||||
<button
|
||||
key={child.key}
|
||||
type="button"
|
||||
className={`floating-nav__subbutton${activeView === child.key ? " is-active" : ""}`}
|
||||
title={`${child.label} / ${child.hint}`}
|
||||
aria-label={child.label}
|
||||
onClick={() => onSelectView(child.key)}
|
||||
>
|
||||
{child.icon}
|
||||
<span>{child.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</aside>
|
||||
) : null}
|
||||
{showPageScrollActions ? (
|
||||
<div className="floating-page-scroll-actions" aria-label="页面滚动">
|
||||
<button
|
||||
type="button"
|
||||
className="floating-page-scroll-actions__button"
|
||||
title="返回页面顶部"
|
||||
aria-label="返回页面顶部"
|
||||
onClick={() => scrollActivePage("top")}
|
||||
>
|
||||
<ShellIcon name="arrow-up" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="floating-page-scroll-actions__button"
|
||||
title="到达页面底部"
|
||||
aria-label="到达页面底部"
|
||||
onClick={() => scrollActivePage("bottom")}
|
||||
>
|
||||
<ShellIcon name="arrow-down" />
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<main className="web-shell__content">
|
||||
{!isImmersiveView ? (
|
||||
<header className="web-topbar">
|
||||
<button className="brand-lockup" type="button" onClick={() => onSelectView("home")}>
|
||||
<span className="brand-lockup__mark" aria-hidden="true">
|
||||
<img className="brand-lockup__logo" src={BRAND_LOGO_URL} alt="" />
|
||||
</span>
|
||||
<span className="brand-lockup__name">OmniAI</span>
|
||||
</button>
|
||||
<div className="web-topbar__actions">
|
||||
<button
|
||||
type="button"
|
||||
className="beta-apply-button"
|
||||
title="内测申请"
|
||||
aria-label="内测申请"
|
||||
onClick={() => setBetaOpen(true)}
|
||||
>
|
||||
内测申请
|
||||
</button>
|
||||
{session && (
|
||||
<NotificationCenter
|
||||
items={notifications}
|
||||
onNavigate={(view, _targetId) => onSelectView(view)}
|
||||
onMarkRead={onMarkNotificationRead}
|
||||
onMarkAllRead={onMarkAllNotificationsRead}
|
||||
/>
|
||||
)}
|
||||
<div className="info-popover-anchor" ref={infoRef}>
|
||||
<button
|
||||
className="info-button"
|
||||
type="button"
|
||||
aria-label="网站信息"
|
||||
onClick={() => setInfoOpen((c) => !c)}
|
||||
>
|
||||
<ShellIcon name="info-circle" />
|
||||
</button>
|
||||
<AnimatedPanel open={infoOpen} className="info-popover panel-surface">
|
||||
<dl>
|
||||
<dt>备案信息</dt>
|
||||
<dd>{publicConfig.icpRecord || "由服务器配置"}</dd>
|
||||
<dt>公司地址</dt>
|
||||
<dd>{publicConfig.companyAddress || "由服务器配置"}</dd>
|
||||
<dt>联系电话</dt>
|
||||
<dd>{publicConfig.contactPhone || "由服务器配置"}</dd>
|
||||
</dl>
|
||||
<div className="info-popover__links">
|
||||
<a href="#/bug-feedback" onClick={() => setInfoOpen(false)}>Bug 反馈</a>
|
||||
<a href="#/userAgreement" onClick={() => setInfoOpen(false)}>用户协议</a>
|
||||
<a href="#/privacyPolicy" onClick={() => setInfoOpen(false)}>隐私政策</a>
|
||||
</div>
|
||||
</AnimatedPanel>
|
||||
</div>
|
||||
<button
|
||||
className="member-button"
|
||||
type="button"
|
||||
aria-label={`积分余额 ${displayedBalanceLabel}`}
|
||||
onClick={() => toast.info("充值功能即将开放,敬请期待")}
|
||||
>
|
||||
<ShellIcon name="wallet" />
|
||||
<span className="member-button__label">{displayedBalanceLabel}</span>
|
||||
</button>
|
||||
<div className="profile-popover-anchor" ref={profileRef}>
|
||||
<button
|
||||
className={`profile-button${session ? " profile-button--member" : " profile-button--guest"}`}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (session) {
|
||||
setProfileOpen((current) => !current);
|
||||
} else {
|
||||
onOpenLogin();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{session ? (
|
||||
<>
|
||||
<span className="profile-button__avatar">
|
||||
{avatarUrl ? <img src={avatarUrl} alt={displayName} /> : avatarLabel}
|
||||
</span>
|
||||
<span>{displayName}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ShellIcon name="login" />
|
||||
<span>登录 / 注册</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<AnimatedPanel open={session ? profileOpen : false} className="profile-popover panel-surface">
|
||||
<div className="profile-popover__head">
|
||||
<span className="profile-popover__avatar">
|
||||
{avatarUrl ? <img src={avatarUrl} alt={displayName} /> : avatarLabel}
|
||||
</span>
|
||||
<div>
|
||||
<strong>{displayName}</strong>
|
||||
<span>{session ? session.user.role || "已登录" : "预览模式"}</span>
|
||||
</div>
|
||||
</div>
|
||||
<dl className="profile-popover__stats">
|
||||
<dt>UID</dt>
|
||||
<dd>{session?.user.id || "preview"}</dd>
|
||||
<dt>{isEnterpriseAccount ? "企业积分" : "积分"}</dt>
|
||||
<dd>{displayedBalanceLabel}</dd>
|
||||
<dt>图片</dt>
|
||||
<dd>{usage.imageUsed}</dd>
|
||||
<dt>视频</dt>
|
||||
<dd>{usage.videoUsed}</dd>
|
||||
</dl>
|
||||
<div className="profile-popover__footer">
|
||||
<span>{session?.source === "server" ? "服务器会话" : "预览会话"}</span>
|
||||
<button type="button" onClick={onLogout}>
|
||||
<ShellIcon name="logout" />
|
||||
退出
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="profile-popover__center-btn"
|
||||
onClick={() => {
|
||||
setProfileOpen(false);
|
||||
onSelectView("login");
|
||||
}}
|
||||
>
|
||||
<ShellIcon name="user" />
|
||||
个人中心
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="profile-popover__report-btn"
|
||||
onClick={() => {
|
||||
setProfileOpen(false);
|
||||
onSelectView("report");
|
||||
}}
|
||||
>
|
||||
<ShellIcon name="flag" />
|
||||
Bug 反馈
|
||||
</button>
|
||||
{showCommunityReview ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="profile-popover__review-btn"
|
||||
onClick={() => {
|
||||
setProfileOpen(false);
|
||||
onSelectView("communityReview");
|
||||
}}
|
||||
>
|
||||
<ShellIcon name="check-circle" />
|
||||
社区审核
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
{showBetaApplicationReview ? (
|
||||
<button
|
||||
type="button"
|
||||
className="profile-popover__review-btn"
|
||||
onClick={() => {
|
||||
setProfileOpen(false);
|
||||
onSelectView("betaApplications");
|
||||
}}
|
||||
>
|
||||
<ShellIcon name="check-circle" />
|
||||
内测申请审核
|
||||
</button>
|
||||
) : null}
|
||||
{showCommunityCaseAdd ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="profile-popover__review-btn"
|
||||
onClick={() => {
|
||||
setProfileOpen(false);
|
||||
onSelectView("communityCaseAdd");
|
||||
}}
|
||||
>
|
||||
<ShellIcon name="plus-circle" />
|
||||
添加案例
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
</AnimatedPanel>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
) : null}
|
||||
<div className="web-shell__page">{children}</div>
|
||||
</main>
|
||||
</div>
|
||||
{session?.user.role === "admin" ? <AdminMonitor /> : null}
|
||||
{rechargeOpen && RechargeModal ? (
|
||||
<RechargeModal open={rechargeOpen} onClose={() => setRechargeOpen(false)} currentBalance={displayedBalanceCents} />
|
||||
) : null}
|
||||
<BetaApplicationModal open={betaOpen} onClose={() => setBetaOpen(false)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AppShell;
|
||||
@@ -0,0 +1,108 @@
|
||||
import { useRef, useState, type CSSProperties } from "react";
|
||||
|
||||
interface BeforeAfterCompareProps {
|
||||
sourceSrc: string;
|
||||
resultSrc: string;
|
||||
sourceLabel?: string;
|
||||
resultLabel?: string;
|
||||
sourceAlt?: string;
|
||||
resultAlt?: string;
|
||||
className?: string;
|
||||
onSourceLoad?: (width: number, height: number) => void;
|
||||
}
|
||||
|
||||
const MIN_POSITION = 5;
|
||||
const MAX_POSITION = 95;
|
||||
|
||||
function clamp(value: number) {
|
||||
return Math.min(MAX_POSITION, Math.max(MIN_POSITION, value));
|
||||
}
|
||||
|
||||
export default function BeforeAfterCompare({
|
||||
sourceSrc,
|
||||
resultSrc,
|
||||
sourceLabel,
|
||||
resultLabel,
|
||||
sourceAlt = "原图",
|
||||
resultAlt = "结果",
|
||||
className = "",
|
||||
onSourceLoad,
|
||||
}: BeforeAfterCompareProps) {
|
||||
const stageRef = useRef<HTMLDivElement>(null);
|
||||
const [position, setPosition] = useState(50);
|
||||
|
||||
const updatePosition = (clientX: number) => {
|
||||
const stage = stageRef.current;
|
||||
if (!stage) return;
|
||||
const rect = stage.getBoundingClientRect();
|
||||
if (!rect.width) return;
|
||||
setPosition(clamp(((clientX - rect.left) / rect.width) * 100));
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={stageRef}
|
||||
className={`before-after-compare ${className}`}
|
||||
style={{ "--compare-position": `${position}%` } as CSSProperties}
|
||||
aria-label="前后对比"
|
||||
>
|
||||
<div className="before-after-compare__layer before-after-compare__layer--source">
|
||||
<img
|
||||
src={sourceSrc}
|
||||
alt={sourceAlt}
|
||||
onLoad={(event) => {
|
||||
onSourceLoad?.(event.currentTarget.naturalWidth, event.currentTarget.naturalHeight);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="before-after-compare__layer before-after-compare__layer--result">
|
||||
<img src={resultSrc} alt={resultAlt} />
|
||||
</div>
|
||||
{sourceLabel && (
|
||||
<div className="before-after-compare__label before-after-compare__label--source">{sourceLabel}</div>
|
||||
)}
|
||||
{resultLabel && (
|
||||
<div className="before-after-compare__label before-after-compare__label--result">{resultLabel}</div>
|
||||
)}
|
||||
<div
|
||||
className="before-after-compare__divider"
|
||||
role="slider"
|
||||
tabIndex={0}
|
||||
aria-label="拖动对比"
|
||||
aria-valuemin={MIN_POSITION}
|
||||
aria-valuemax={MAX_POSITION}
|
||||
aria-valuenow={Math.round(position)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "ArrowLeft") {
|
||||
event.preventDefault();
|
||||
setPosition((current) => clamp(current - 2));
|
||||
}
|
||||
if (event.key === "ArrowRight") {
|
||||
event.preventDefault();
|
||||
setPosition((current) => clamp(current + 2));
|
||||
}
|
||||
}}
|
||||
onPointerDown={(event) => {
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
updatePosition(event.clientX);
|
||||
}}
|
||||
onPointerMove={(event) => {
|
||||
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return;
|
||||
updatePosition(event.clientX);
|
||||
}}
|
||||
onPointerUp={(event) => {
|
||||
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
|
||||
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
}
|
||||
}}
|
||||
onPointerCancel={(event) => {
|
||||
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
|
||||
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
import { CloseOutlined, ExperimentOutlined } from "@ant-design/icons";
|
||||
import { useState } from "react";
|
||||
import { betaApplicationClient } from "../api/betaApplicationClient";
|
||||
|
||||
interface BetaApplicationModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/* ── Form state ── */
|
||||
interface BetaFormData {
|
||||
name: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
wechat: string;
|
||||
industry: string;
|
||||
company: string;
|
||||
city: string;
|
||||
aiTools: string;
|
||||
aiDuration: string;
|
||||
aiTrack: string;
|
||||
aiDirection: string[];
|
||||
weeklyUsage: string;
|
||||
feedbackWilling: string;
|
||||
wantFeature: string[];
|
||||
selfStatement: string;
|
||||
signature: string;
|
||||
applicationDate: string;
|
||||
agreeRules: boolean;
|
||||
}
|
||||
|
||||
const INITIAL_FORM: BetaFormData = {
|
||||
name: "",
|
||||
email: "",
|
||||
phone: "",
|
||||
wechat: "",
|
||||
industry: "",
|
||||
company: "",
|
||||
city: "",
|
||||
aiTools: "",
|
||||
aiDuration: "",
|
||||
aiTrack: "",
|
||||
aiDirection: [],
|
||||
weeklyUsage: "",
|
||||
feedbackWilling: "",
|
||||
wantFeature: [],
|
||||
selfStatement: "",
|
||||
signature: "",
|
||||
applicationDate: "",
|
||||
agreeRules: false,
|
||||
};
|
||||
|
||||
/* ── Option groups (from the docx) ── */
|
||||
const AI_DURATION_OPTIONS = ["1年以内", "1-3年", "3-5年", "5年以上"];
|
||||
const AI_TRACK_OPTIONS = ["是,长期承接相关业务", "业余创作", "新手学习"];
|
||||
const AI_DIRECTION_OPTIONS = [
|
||||
"AI短剧批量制作", "漫剧剧情生成", "自媒体短视频", "电商图文及视频素材",
|
||||
"MCN商业内容", "企业宣传视频", "个人兴趣创作", "其他",
|
||||
];
|
||||
const WEEKLY_USAGE_OPTIONS = ["7次及以上", "1-3次", "空闲时间使用"];
|
||||
const FEEDBACK_OPTIONS = ["全力配合深度反馈", "简单体验留言", "仅使用不反馈"];
|
||||
const WANT_FEATURE_OPTIONS = [
|
||||
"一站式短剧漫剧完整AIGC工作流", "电商素材自动化创作流程",
|
||||
"多模态智能中枢全能创作", "批量自动化创作流程", "全新未公开AI创作玩法",
|
||||
];
|
||||
|
||||
/* ── Helper: single-select radio group ── */
|
||||
function RadioGroup({
|
||||
name, options, value, onChange,
|
||||
}: {
|
||||
name: string;
|
||||
options: string[];
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="beta-radio-group">
|
||||
{options.map((opt) => (
|
||||
<label key={opt} className="beta-radio">
|
||||
<input
|
||||
type="radio"
|
||||
name={name}
|
||||
checked={value === opt}
|
||||
onChange={() => onChange(opt)}
|
||||
/>
|
||||
<span>{opt}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Helper: multi-select checkbox group ── */
|
||||
function CheckboxGroup({
|
||||
options, value, onChange,
|
||||
}: {
|
||||
options: string[];
|
||||
value: string[];
|
||||
onChange: (v: string[]) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="beta-checkbox-group">
|
||||
{options.map((opt) => (
|
||||
<label key={opt} className="beta-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={value.includes(opt)}
|
||||
onChange={() => {
|
||||
if (value.includes(opt)) {
|
||||
onChange(value.filter((item) => item !== opt));
|
||||
} else {
|
||||
onChange([...value, opt]);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span>{opt}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Helper: text field ── */
|
||||
function TextField({
|
||||
label, value, onChange, placeholder,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
placeholder?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="beta-text-field">
|
||||
<span className="beta-text-field__label">{label}</span>
|
||||
<input
|
||||
type="text"
|
||||
className="beta-text-field__input"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder ?? "请填写"}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const BetaApplicationModal = ({ open, onClose }: BetaApplicationModalProps) => {
|
||||
const [form, setForm] = useState<BetaFormData>(INITIAL_FORM);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [message, setMessage] = useState<{ tone: "success" | "error"; text: string } | null>(null);
|
||||
|
||||
const update = <K extends keyof BetaFormData>(key: K, value: BetaFormData[K]) => {
|
||||
setForm((prev) => ({ ...prev, [key]: value }));
|
||||
setMessage(null);
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
if (submitting) return;
|
||||
onClose();
|
||||
};
|
||||
|
||||
const validate = () => {
|
||||
if (!form.name.trim()) return "请填写姓名 / 常用昵称";
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email.trim())) return "请填写用于接收内测码的有效邮箱";
|
||||
if (!form.phone.trim()) return "请填写联系手机号码";
|
||||
if (!form.wechat.trim()) return "请填写微信账号";
|
||||
if (!form.selfStatement.trim()) return "请填写申请自述";
|
||||
if (!form.signature.trim()) return "请填写申请人确认签字";
|
||||
if (!form.applicationDate.trim()) return "请填写申请日期";
|
||||
if (!form.agreeRules) return "请先阅读并同意内测规则";
|
||||
return null;
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
if (submitting) return;
|
||||
const validationError = validate();
|
||||
if (validationError) {
|
||||
setMessage({ tone: "error", text: validationError });
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
setMessage(null);
|
||||
try {
|
||||
await betaApplicationClient.submit({
|
||||
...form,
|
||||
name: form.name.trim(),
|
||||
email: form.email.trim(),
|
||||
phone: form.phone.trim(),
|
||||
wechat: form.wechat.trim(),
|
||||
industry: form.industry.trim(),
|
||||
company: form.company.trim(),
|
||||
city: form.city.trim(),
|
||||
aiTools: form.aiTools.trim(),
|
||||
aiDuration: form.aiDuration.trim(),
|
||||
aiTrack: form.aiTrack.trim(),
|
||||
weeklyUsage: form.weeklyUsage.trim(),
|
||||
feedbackWilling: form.feedbackWilling.trim(),
|
||||
selfStatement: form.selfStatement.trim(),
|
||||
signature: form.signature.trim(),
|
||||
applicationDate: form.applicationDate.trim(),
|
||||
});
|
||||
setForm(INITIAL_FORM);
|
||||
setMessage({ tone: "success", text: "申请已提交,请留意预留邮箱中的审核结果。" });
|
||||
} catch (error) {
|
||||
setMessage({ tone: "error", text: error instanceof Error ? error.message : "提交内测申请失败" });
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="beta-application-modal" role="dialog" aria-modal="true" aria-labelledby="beta-modal-title">
|
||||
<button type="button" className="beta-application-modal__backdrop" onClick={close} aria-label="关闭内测申请弹窗" />
|
||||
|
||||
<section className="beta-application-modal__panel">
|
||||
{/* ── Header ── */}
|
||||
<header className="beta-modal-header">
|
||||
<div className="beta-modal-header__left">
|
||||
<ExperimentOutlined className="beta-modal-header__icon" />
|
||||
<div>
|
||||
<h2 id="beta-modal-title">OmniAI 内测体验官申请表</h2>
|
||||
<p className="beta-modal-header__subtitle">封闭限量内测 · 仅限 <strong>30 人</strong> · 赠送 <strong>500 元等值 50,000 积分</strong></p>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" className="beta-modal-header__close" onClick={close} aria-label="关闭" disabled={submitting}>
|
||||
<CloseOutlined />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{/* ── Body (scrollable document) ── */}
|
||||
<div className="beta-modal-body">
|
||||
|
||||
{/* 一、个人基础信息 */}
|
||||
<section className="beta-doc-section">
|
||||
<h3 className="beta-doc-section__title">一、个人基础信息</h3>
|
||||
<div className="beta-doc-grid">
|
||||
<TextField label="姓名 / 常用昵称" value={form.name} onChange={(v) => update("name", v)} />
|
||||
<TextField label="接收内测码邮箱" value={form.email} onChange={(v) => update("email", v)} placeholder="审核通过后内测码将发送到此邮箱" />
|
||||
<TextField label="联系手机号码" value={form.phone} onChange={(v) => update("phone", v)} />
|
||||
<TextField label="微信账号" value={form.wechat} onChange={(v) => update("wechat", v)} />
|
||||
<TextField label="所在行业 / 职业" value={form.industry} onChange={(v) => update("industry", v)} />
|
||||
<TextField label="所属公司 / 机构" value={form.company} onChange={(v) => update("company", v)} />
|
||||
<TextField label="所在城市" value={form.city} onChange={(v) => update("city", v)} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 二、AI从业与使用经历 */}
|
||||
<section className="beta-doc-section">
|
||||
<h3 className="beta-doc-section__title">二、AI 从业与使用经历</h3>
|
||||
<div className="beta-doc-grid">
|
||||
<TextField label="日常常用 AI 创作工具有哪些" value={form.aiTools} onChange={(v) => update("aiTools", v)} placeholder="例如:Midjourney / Stable Diffusion / ChatGPT 等" />
|
||||
<div className="beta-form-group">
|
||||
<span className="beta-form-group__label">AI 内容创作从业时长</span>
|
||||
<RadioGroup name="aiDuration" options={AI_DURATION_OPTIONS} value={form.aiDuration} onChange={(v) => update("aiDuration", v)} />
|
||||
</div>
|
||||
<div className="beta-form-group">
|
||||
<span className="beta-form-group__label">是否深耕 AI 短剧、漫剧、数字视频、电商赛道</span>
|
||||
<RadioGroup name="aiTrack" options={AI_TRACK_OPTIONS} value={form.aiTrack} onChange={(v) => update("aiTrack", v)} />
|
||||
</div>
|
||||
<div className="beta-form-group beta-form-group--full">
|
||||
<span className="beta-form-group__label">日常主要创作方向(可多选)</span>
|
||||
<CheckboxGroup options={AI_DIRECTION_OPTIONS} value={form.aiDirection} onChange={(v) => update("aiDirection", v)} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 三、内测使用意向调研 */}
|
||||
<section className="beta-doc-section">
|
||||
<h3 className="beta-doc-section__title">三、内测使用意向调研</h3>
|
||||
<div className="beta-doc-grid">
|
||||
<div className="beta-form-group">
|
||||
<span className="beta-form-group__label">每周可稳定登录使用内测平台次数</span>
|
||||
<RadioGroup name="weeklyUsage" options={WEEKLY_USAGE_OPTIONS} value={form.weeklyUsage} onChange={(v) => update("weeklyUsage", v)} />
|
||||
</div>
|
||||
<div className="beta-form-group">
|
||||
<span className="beta-form-group__label">是否愿意积极反馈产品 BUG、优化建议、功能需求</span>
|
||||
<RadioGroup name="feedback" options={FEEDBACK_OPTIONS} value={form.feedbackWilling} onChange={(v) => update("feedbackWilling", v)} />
|
||||
</div>
|
||||
<div className="beta-form-group beta-form-group--full">
|
||||
<span className="beta-form-group__label">本次最想体验 OmniAI 核心功能(可多选)</span>
|
||||
<CheckboxGroup options={WANT_FEATURE_OPTIONS} value={form.wantFeature} onChange={(v) => update("wantFeature", v)} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 四、申请自述 */}
|
||||
<section className="beta-doc-section">
|
||||
<h3 className="beta-doc-section__title">四、申请自述 <em className="beta-required">(必填)</em></h3>
|
||||
<p className="beta-doc-section__desc">请简述自身 AI 创作优势、业务需求,以及加入本次封闭内测的理由:</p>
|
||||
<textarea
|
||||
className="beta-textarea"
|
||||
value={form.selfStatement}
|
||||
onChange={(e) => update("selfStatement", e.target.value)}
|
||||
placeholder="请在此填写您的申请自述(必填)…"
|
||||
rows={6}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* 五、内测规则知情同意书 */}
|
||||
<section className="beta-doc-section">
|
||||
<h3 className="beta-doc-section__title">五、内测规则知情同意书</h3>
|
||||
<ol className="beta-rules-list">
|
||||
<li>本次为封闭限量内测,仅限 <strong>30 人</strong>,按照资质匹配度 + 申请顺序筛选;</li>
|
||||
<li>内测赠送 <strong>500 元等值 50,000 积分</strong>,仅限内测期间使用,不可提现、不可转让、不可兑换现金;</li>
|
||||
<li>内测版本含未上线测试功能,存在功能不稳定、界面调整、参数优化等情况,申请人自愿理解包容;</li>
|
||||
<li>严禁私自泄露内测专属工作流、内部功能、测试接口、未发布技术方案等内部资料;</li>
|
||||
<li>审核通过后,官方将在 <strong>48 小时</strong> 内通过预留邮箱发放内测码、登录权限及免费积分;</li>
|
||||
<li>正式版上线后,优质内测体验官可享受专属永久优惠权限与平台荣誉称号。</li>
|
||||
</ol>
|
||||
|
||||
<label className="beta-agree-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.agreeRules}
|
||||
onChange={(e) => update("agreeRules", e.target.checked)}
|
||||
/>
|
||||
<span>本人已完整阅读并同意以上全部内测规则,自愿遵守内测所有管理要求。</span>
|
||||
</label>
|
||||
|
||||
<div className="beta-doc-grid beta-doc-grid--two">
|
||||
<TextField label="申请人确认签字" value={form.signature} onChange={(v) => update("signature", v)} placeholder="请签署姓名" />
|
||||
<TextField label="申请填写日期" value={form.applicationDate} onChange={(v) => update("applicationDate", v)} placeholder="例如:2026年6月8日" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
|
||||
{/* ── Footer ── */}
|
||||
<footer className="beta-modal-footer">
|
||||
{message ? (
|
||||
<p className={`beta-modal-footer__message beta-modal-footer__message--${message.tone}`} role="status">
|
||||
{message.text}
|
||||
</p>
|
||||
) : null}
|
||||
<button type="button" className="beta-modal-footer__btn beta-modal-footer__btn--secondary" onClick={close} disabled={submitting}>
|
||||
关闭
|
||||
</button>
|
||||
<button type="button" className="beta-modal-footer__btn beta-modal-footer__btn--primary" onClick={() => void submit()} disabled={submitting}>
|
||||
{submitting ? "提交中..." : "提交申请"}
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BetaApplicationModal;
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
const COOKIE_CONSENT_KEY = "omniai:cookie-consent:v1";
|
||||
|
||||
export default function CookieConsentBanner() {
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setVisible(localStorage.getItem(COOKIE_CONSENT_KEY) !== "accepted");
|
||||
}, []);
|
||||
|
||||
const accept = () => {
|
||||
localStorage.setItem(COOKIE_CONSENT_KEY, "accepted");
|
||||
setVisible(false);
|
||||
};
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<section className="cookie-consent" role="dialog" aria-live="polite" aria-label="Cookie 使用提示">
|
||||
<div>
|
||||
<strong>Cookie 与本地存储提示</strong>
|
||||
<p>我们使用 Cookie 和本地存储保存登录状态、偏好设置、创作草稿和断点续传数据,用于保障服务正常运行。</p>
|
||||
</div>
|
||||
<div className="cookie-consent__actions">
|
||||
<a href="#/privacyPolicy">查看隐私政策</a>
|
||||
<button type="button" onClick={accept}>同意并继续</button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { useCallback, useRef, useState, type ReactNode } from "react";
|
||||
import "../styles/components/dropzone.css";
|
||||
|
||||
interface DropZoneProps {
|
||||
accept?: string;
|
||||
multiple?: boolean;
|
||||
onFiles: (files: File[]) => void;
|
||||
children?: ReactNode;
|
||||
className?: string;
|
||||
label?: string;
|
||||
hint?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export default function DropZone({
|
||||
accept = "image/*",
|
||||
multiple = false,
|
||||
onFiles,
|
||||
children,
|
||||
className = "",
|
||||
label = "拖入文件或点击上传",
|
||||
hint,
|
||||
disabled = false,
|
||||
}: DropZoneProps) {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const dragCounter = useRef(0);
|
||||
|
||||
const handleDragEnter = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
dragCounter.current += 1;
|
||||
setIsDragging(true);
|
||||
}, []);
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
dragCounter.current -= 1;
|
||||
if (dragCounter.current <= 0) {
|
||||
dragCounter.current = 0;
|
||||
setIsDragging(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
dragCounter.current = 0;
|
||||
setIsDragging(false);
|
||||
if (disabled) return;
|
||||
const acceptTypes = accept.split(",").map((t) => t.trim());
|
||||
const files = Array.from(e.dataTransfer.files).filter((f) =>
|
||||
acceptTypes.some((t) => {
|
||||
if (t.endsWith("/*")) return f.type.startsWith(t.replace("/*", "/"));
|
||||
return f.type === t || f.name.endsWith(t);
|
||||
}),
|
||||
);
|
||||
if (files.length) onFiles(multiple ? files : files.slice(0, 1));
|
||||
},
|
||||
[accept, disabled, multiple, onFiles],
|
||||
);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(e.target.files || []);
|
||||
if (files.length) onFiles(multiple ? files : files.slice(0, 1));
|
||||
e.target.value = "";
|
||||
},
|
||||
[multiple, onFiles],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`dropzone${isDragging ? " dropzone--active" : ""} ${className}`}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => !disabled && inputRef.current?.click()}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") inputRef.current?.click(); }}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept={accept}
|
||||
multiple={multiple}
|
||||
onChange={handleChange}
|
||||
style={{ display: "none" }}
|
||||
/>
|
||||
{children || (
|
||||
<>
|
||||
<strong className="dropzone__label">{label}</strong>
|
||||
{hint && <span className="dropzone__hint">{hint}</span>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { ReactNode } from "react";
|
||||
import "../styles/components/empty-state.css";
|
||||
|
||||
interface EmptyStateProps {
|
||||
icon?: ReactNode;
|
||||
title: string;
|
||||
description?: string;
|
||||
actionLabel?: string;
|
||||
onAction?: () => void;
|
||||
}
|
||||
|
||||
export function EmptyState({ icon, title, description, actionLabel, onAction }: EmptyStateProps) {
|
||||
return (
|
||||
<div className="empty-state" role="status">
|
||||
<div className="empty-state__illustration">
|
||||
{icon || (
|
||||
<svg width="120" height="96" viewBox="0 0 120 96" fill="none" aria-hidden="true">
|
||||
<rect x="20" y="20" width="80" height="56" rx="8" stroke="currentColor" strokeWidth="2" strokeDasharray="4 3" opacity="0.3" />
|
||||
<circle cx="60" cy="42" r="12" stroke="currentColor" strokeWidth="2" opacity="0.4" />
|
||||
<path d="M54 42l4 4 8-8" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" opacity="0.4" />
|
||||
<rect x="36" y="62" width="48" height="4" rx="2" fill="currentColor" opacity="0.15" />
|
||||
<rect x="44" y="70" width="32" height="3" rx="1.5" fill="currentColor" opacity="0.1" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<strong className="empty-state__title">{title}</strong>
|
||||
{description ? <p className="empty-state__desc">{description}</p> : null}
|
||||
{actionLabel && onAction ? (
|
||||
<button type="button" className="empty-state__action" onClick={onAction}>
|
||||
{actionLabel}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { Component, type ReactNode } from "react";
|
||||
import { reportError } from "../utils/errorReporting";
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
class ErrorBoundary extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = { hasError: false, error: null };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: React.ErrorInfo) {
|
||||
console.error("[ErrorBoundary] Uncaught error:", error, info.componentStack);
|
||||
reportError(error, "boundary");
|
||||
}
|
||||
|
||||
handleReset = () => {
|
||||
this.setState({ hasError: false, error: null });
|
||||
};
|
||||
|
||||
handleReload = () => {
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
render() {
|
||||
if (!this.state.hasError) {
|
||||
return this.props.children;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
minHeight: "100vh",
|
||||
background: "var(--bg-base, #f5f5f5)",
|
||||
padding: 24,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
maxWidth: 480,
|
||||
padding: "48px 40px",
|
||||
borderRadius: 16,
|
||||
background: "var(--surface-panel, #fff)",
|
||||
boxShadow: "0 4px 24px rgba(0,0,0,0.08)",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 48, marginBottom: 16 }}>⚠️</div>
|
||||
<h2
|
||||
style={{
|
||||
margin: "0 0 12px",
|
||||
fontSize: 20,
|
||||
fontWeight: 600,
|
||||
color: "var(--text-primary, #1a1a1a)",
|
||||
}}
|
||||
>
|
||||
页面出错了
|
||||
</h2>
|
||||
<p
|
||||
style={{
|
||||
margin: "0 0 24px",
|
||||
fontSize: 14,
|
||||
color: "var(--text-secondary, #666)",
|
||||
lineHeight: 1.6,
|
||||
}}
|
||||
>
|
||||
应用遇到了意外错误,请尝试刷新页面。
|
||||
<br />
|
||||
如果问题持续出现,请联系技术支持。
|
||||
</p>
|
||||
{this.state.error && (
|
||||
<details
|
||||
style={{
|
||||
marginBottom: 24,
|
||||
padding: 12,
|
||||
borderRadius: 8,
|
||||
background: "var(--bg-elevated, #f0f0f0)",
|
||||
textAlign: "left",
|
||||
fontSize: 12,
|
||||
color: "var(--text-secondary, #666)",
|
||||
}}
|
||||
>
|
||||
<summary style={{ cursor: "pointer", fontWeight: 500 }}>
|
||||
错误详情
|
||||
</summary>
|
||||
<pre
|
||||
style={{
|
||||
marginTop: 8,
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-word",
|
||||
fontFamily: "monospace",
|
||||
fontSize: 11,
|
||||
}}
|
||||
>
|
||||
{this.state.error.message}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
<div style={{ display: "flex", gap: 12, justifyContent: "center" }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={this.handleReset}
|
||||
style={{
|
||||
padding: "10px 24px",
|
||||
borderRadius: 8,
|
||||
border: "1px solid var(--border-normal, #ddd)",
|
||||
background: "transparent",
|
||||
color: "var(--text-primary, #1a1a1a)",
|
||||
fontSize: 14,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={this.handleReload}
|
||||
style={{
|
||||
padding: "10px 24px",
|
||||
borderRadius: 8,
|
||||
border: "none",
|
||||
background: "var(--accent, #0d9488)",
|
||||
color: "#fff",
|
||||
fontSize: 14,
|
||||
fontWeight: 500,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
刷新页面
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default ErrorBoundary;
|
||||
@@ -0,0 +1,25 @@
|
||||
import { HomeOutlined } from "@ant-design/icons";
|
||||
import { useCallback } from "react";
|
||||
import "../styles/pages/not-found.css";
|
||||
|
||||
interface NotFoundPageProps {
|
||||
onGoHome: () => void;
|
||||
}
|
||||
|
||||
function NotFoundPage({ onGoHome }: NotFoundPageProps) {
|
||||
return (
|
||||
<section className="not-found-page page-motion">
|
||||
<div className="not-found-page__content">
|
||||
<div className="not-found-page__code">404</div>
|
||||
<h1>页面未找到</h1>
|
||||
<p>您访问的页面不存在或已被移除。</p>
|
||||
<button type="button" className="not-found-page__button" onClick={onGoHome}>
|
||||
<HomeOutlined />
|
||||
返回首页
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default NotFoundPage;
|
||||
@@ -0,0 +1,157 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { WebNotification, WebNotificationType, WebViewKey } from "../types";
|
||||
import { AnimatedPanel } from "./AnimatedPanel";
|
||||
import { ShellIcon } from "./ShellIcon";
|
||||
|
||||
const NOTIFICATION_ICONS: Record<WebNotificationType, React.ReactNode> = {
|
||||
task_completed: <ShellIcon name="check-circle" style={{ color: "#10b981" }} />,
|
||||
task_failed: <ShellIcon name="close-circle" style={{ color: "#ef4444" }} />,
|
||||
review_pending: <ShellIcon name="exclamation-circle" style={{ color: "#f59e0b" }} />,
|
||||
review_passed: <ShellIcon name="like" style={{ color: "#10b981" }} />,
|
||||
review_rejected: <ShellIcon name="dislike" style={{ color: "#f59e0b" }} />,
|
||||
credits_low: <ShellIcon name="exclamation-circle" style={{ color: "#f59e0b" }} />,
|
||||
session_expired: <ShellIcon name="lock" style={{ color: "#ef4444" }} />,
|
||||
info: <ShellIcon name="bell" style={{ color: "#2563eb" }} />,
|
||||
};
|
||||
|
||||
function parseTimestamp(dateStr: string): number {
|
||||
if (!dateStr) return Date.now();
|
||||
let ts = new Date(dateStr).getTime();
|
||||
if (Number.isNaN(ts)) {
|
||||
ts = new Date(dateStr.replace(" ", "T") + "Z").getTime();
|
||||
}
|
||||
if (Number.isNaN(ts)) return Date.now();
|
||||
return ts;
|
||||
}
|
||||
|
||||
function timeAgo(dateStr: string, now: number): string {
|
||||
const ts = parseTimestamp(dateStr);
|
||||
const diff = now - ts;
|
||||
if (diff < 0) return "刚刚";
|
||||
const seconds = Math.floor(diff / 1000);
|
||||
if (seconds < 60) return "刚刚";
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return `${minutes}分钟前`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}小时前`;
|
||||
const days = Math.floor(hours / 24);
|
||||
if (days < 30) return `${days}天前`;
|
||||
const date = new Date(ts);
|
||||
return `${date.getMonth() + 1}月${date.getDate()}日`;
|
||||
}
|
||||
|
||||
interface NotificationCenterProps {
|
||||
items?: WebNotification[];
|
||||
onNavigate: (view: WebViewKey, targetId?: string) => void;
|
||||
onMarkRead?: (id: string, isRead?: boolean) => void;
|
||||
onMarkAllRead?: () => void;
|
||||
onClear?: () => void;
|
||||
}
|
||||
|
||||
function NotificationCenter({ items, onNavigate, onMarkRead, onMarkAllRead, onClear }: NotificationCenterProps) {
|
||||
const [readIds, setReadIds] = useState<string[]>([]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [now, setNow] = useState(Date.now);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const notifications = items ?? [];
|
||||
const unreadCount = notifications.filter((n) => !readIds.includes(n.id) && !n.isRead).length;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const timer = setInterval(() => setNow(Date.now()), 30_000);
|
||||
return () => clearInterval(timer);
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (items && items.length === 0) {
|
||||
setReadIds([]);
|
||||
}
|
||||
}, [items]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handlePointerDown = (e: PointerEvent) => {
|
||||
if (!containerRef.current?.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("pointerdown", handlePointerDown);
|
||||
return () => document.removeEventListener("pointerdown", handlePointerDown);
|
||||
}, [open]);
|
||||
|
||||
const markAllRead = () => {
|
||||
setReadIds((prev) => Array.from(new Set([...prev, ...notifications.map((n) => n.id)])));
|
||||
onMarkAllRead?.();
|
||||
};
|
||||
|
||||
const handleClickNotification = (n: WebNotification) => {
|
||||
setReadIds((prev) => (prev.includes(n.id) ? prev : [...prev, n.id]));
|
||||
onMarkRead?.(n.id, true);
|
||||
setOpen(false);
|
||||
if (n.targetView) {
|
||||
onNavigate(n.targetView, n.targetId);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="notification-center" ref={containerRef}>
|
||||
<button
|
||||
className="notification-center__bell"
|
||||
type="button"
|
||||
title="通知中心"
|
||||
aria-label={`通知中心${unreadCount > 0 ? `,${unreadCount}条未读` : ""}`}
|
||||
onClick={() => { setOpen((v) => !v); setNow(Date.now()); }}
|
||||
>
|
||||
<ShellIcon name="bell" />
|
||||
{unreadCount > 0 && (
|
||||
<span className="notification-center__badge">{unreadCount > 99 ? "99+" : unreadCount}</span>
|
||||
)}
|
||||
</button>
|
||||
<AnimatedPanel open={open} className="notification-center__panel" exitDuration={140}>
|
||||
<div className="notification-center__header">
|
||||
<span className="notification-center__title">通知中心</span>
|
||||
<div className="notification-center__header-actions">
|
||||
{unreadCount > 0 && (
|
||||
<button className="notification-center__mark-read" type="button" onClick={markAllRead}>
|
||||
全部已读
|
||||
</button>
|
||||
)}
|
||||
{notifications.length > 0 && onClear && (
|
||||
<button className="notification-center__clear" type="button" onClick={() => { onClear(); setOpen(false); }}>
|
||||
<ShellIcon name="delete" /> 清空
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="notification-center__list">
|
||||
{notifications.length === 0 ? (
|
||||
<div className="notification-center__empty">
|
||||
<ShellIcon name="bell" style={{ fontSize: 28, opacity: 0.3 }} />
|
||||
<span>暂无通知</span>
|
||||
</div>
|
||||
) : (
|
||||
notifications.map((n) => (
|
||||
<button
|
||||
key={n.id}
|
||||
type="button"
|
||||
className={`notification-center__item${n.isRead || readIds.includes(n.id) ? "" : " is-unread"}`}
|
||||
onClick={() => handleClickNotification(n)}
|
||||
>
|
||||
<span className="notification-center__item-icon">
|
||||
{NOTIFICATION_ICONS[n.type]}
|
||||
</span>
|
||||
<div className="notification-center__item-body">
|
||||
<span className="notification-center__item-title">{n.title}</span>
|
||||
<span className="notification-center__item-desc">{n.description}</span>
|
||||
</div>
|
||||
<span className="notification-center__item-time">{timeAgo(n.createdAt, now)}</span>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</AnimatedPanel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default NotificationCenter;
|
||||
@@ -0,0 +1,500 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { CloseOutlined, LeftOutlined, RightOutlined } from "@ant-design/icons";
|
||||
import "../styles/components/onboarding.css";
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────
|
||||
|
||||
export type TourPhaseId = "chat" | "image" | "video";
|
||||
|
||||
interface TooltipStep {
|
||||
target: string;
|
||||
title: string;
|
||||
description: string;
|
||||
/** Which side of the target to place the tooltip on (preferred). */
|
||||
placement?: "top" | "bottom" | "left" | "right";
|
||||
/** If true, this step requires the user to interact with the element to proceed. */
|
||||
interactive?: boolean;
|
||||
/** Shown as hint text when interactive. */
|
||||
actionHint?: string;
|
||||
}
|
||||
|
||||
interface TourPhase {
|
||||
id: TourPhaseId;
|
||||
label: string;
|
||||
steps: TooltipStep[];
|
||||
}
|
||||
|
||||
interface OnboardingTourProps {
|
||||
active: boolean;
|
||||
phase: TourPhaseId;
|
||||
stepIndex: number;
|
||||
onNext: (phase: TourPhaseId, stepIndex: number) => void;
|
||||
onSkip: (phase: TourPhaseId) => void;
|
||||
onDone: () => void;
|
||||
}
|
||||
|
||||
// ─── Tour definitions ────────────────────────────────────────
|
||||
|
||||
const PHASES: Record<TourPhaseId, TourPhase> = {
|
||||
chat: {
|
||||
id: "chat",
|
||||
label: "对话模式",
|
||||
steps: [
|
||||
{
|
||||
target: "onboarding-chat-upload",
|
||||
title: "参考素材上传",
|
||||
description: "点击或拖拽上传图片、视频、音频等参考素材,帮助 AI 更好地理解你的需求。",
|
||||
placement: "right",
|
||||
},
|
||||
{
|
||||
target: "onboarding-chat-model",
|
||||
title: "AI 模型选择",
|
||||
description: "在这里选择对话使用的 AI 模型,不同模型有不同的擅长领域和风格。",
|
||||
placement: "bottom",
|
||||
},
|
||||
{
|
||||
target: "onboarding-chat-speed",
|
||||
title: "思考速度",
|
||||
description: "「思考速度:高」回复更迅速简洁;「思考速度:急速」适合快速问答场景。",
|
||||
placement: "bottom",
|
||||
},
|
||||
{
|
||||
target: "onboarding-chat-depth",
|
||||
title: "推理深度",
|
||||
description: "「推理深度:强」进行更深层逻辑推理;「推理深度:极限」适合复杂多步骤问题。",
|
||||
placement: "bottom",
|
||||
},
|
||||
{
|
||||
target: "onboarding-chat-input",
|
||||
title: "提示词输入框",
|
||||
description: "在这里输入你的问题或创作需求,按 Enter 发送,Shift + Enter 换行。",
|
||||
placement: "top",
|
||||
},
|
||||
{
|
||||
target: "onboarding-mode-selector",
|
||||
title: "切换到图像生成模式",
|
||||
description: "点击「下一步」自动切换,或点击这个按钮手动选择「图像生成」进入下一阶段。",
|
||||
placement: "bottom",
|
||||
},
|
||||
],
|
||||
},
|
||||
image: {
|
||||
id: "image",
|
||||
label: "图像生成",
|
||||
steps: [
|
||||
{
|
||||
target: "onboarding-image-upload",
|
||||
title: "参考图上传",
|
||||
description: "上传参考图片,AI 将基于参考图的风格和内容生成新图像。支持 PNG / JPG / WebP。",
|
||||
placement: "right",
|
||||
},
|
||||
{
|
||||
target: "onboarding-image-model",
|
||||
title: "图像模型选择",
|
||||
description: "选择用于图像生成的 AI 模型,不同模型在风格、精度和速度上有所侧重。",
|
||||
placement: "bottom",
|
||||
},
|
||||
{
|
||||
target: "onboarding-image-settings",
|
||||
title: "比例与分辨率",
|
||||
description: "设置生成图像的宽高比(如 16:9、1:1)和清晰度(1K/2K),根据使用场景选择。",
|
||||
placement: "bottom",
|
||||
},
|
||||
{
|
||||
target: "onboarding-image-grid",
|
||||
title: "单图 / 多宫格模式",
|
||||
description: "「单图」生成一张完整图像;「多宫格」一次生成多张变体供你挑选最佳方案。",
|
||||
placement: "bottom",
|
||||
},
|
||||
{
|
||||
target: "onboarding-image-input",
|
||||
title: "图像提示词",
|
||||
description: "描述你想要的图像内容、风格和细节,越具体效果越好。",
|
||||
placement: "top",
|
||||
},
|
||||
{
|
||||
target: "onboarding-mode-selector",
|
||||
title: "切换到视频生成模式",
|
||||
description: "点击「下一步」自动切换,或点击这个按钮手动选择「视频生成」进入下一阶段。",
|
||||
placement: "bottom",
|
||||
},
|
||||
],
|
||||
},
|
||||
video: {
|
||||
id: "video",
|
||||
label: "视频生成",
|
||||
steps: [
|
||||
{
|
||||
target: "onboarding-video-upload",
|
||||
title: "参考素材上传",
|
||||
description: "上传参考图片或视频片段,帮助 AI 确定视频的风格、色调和内容方向。",
|
||||
placement: "right",
|
||||
},
|
||||
{
|
||||
target: "onboarding-video-model",
|
||||
title: "视频模型选择",
|
||||
description: "选择视频生成模型。不同模型在画质、时长、运动流畅度上各有优势。",
|
||||
placement: "bottom",
|
||||
},
|
||||
{
|
||||
target: "onboarding-video-frame",
|
||||
title: "生成方式:全能 / 首尾帧",
|
||||
description: "「全能参考」根据描述直接生成;「首尾帧」通过设定起始和结束画面精确控制转场。",
|
||||
placement: "bottom",
|
||||
},
|
||||
{
|
||||
target: "onboarding-video-ratio",
|
||||
title: "视频画面比例",
|
||||
description: "选择画面比例。9:16 适合手机短视频(抖音/Reels),16:9 适合横屏展示。",
|
||||
placement: "bottom",
|
||||
},
|
||||
{
|
||||
target: "onboarding-video-duration",
|
||||
title: "视频时长设置",
|
||||
description: "设置生成视频的秒数。时长越长,生成时间越久,建议从 5 秒开始尝试。",
|
||||
placement: "bottom",
|
||||
},
|
||||
{
|
||||
target: "onboarding-video-quality",
|
||||
title: "分辨率与画质",
|
||||
description: "选择视频清晰度。720P 生成更快适合预览,1080P 画质更高适合最终成品。",
|
||||
placement: "bottom",
|
||||
},
|
||||
{
|
||||
target: "onboarding-video-generate",
|
||||
title: "一切就绪,开始创作!",
|
||||
description: "设置完毕后,点击发送按钮(或按 Enter)即可开始你的首次视频生成。祝你创作愉快!",
|
||||
placement: "top",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
// ─── Connector line calculation ──────────────────────────────
|
||||
|
||||
interface ConnectorPoints {
|
||||
x1: number; y1: number; // tooltip edge center
|
||||
x2: number; y2: number; // target edge center
|
||||
}
|
||||
|
||||
function calcConnector(
|
||||
tooltipRect: DOMRect,
|
||||
targetRect: DOMRect,
|
||||
placement: TooltipStep["placement"],
|
||||
): ConnectorPoints {
|
||||
const tx = targetRect.left + targetRect.width / 2;
|
||||
const ty = targetRect.top + targetRect.height / 2;
|
||||
const tcx = tooltipRect.left + tooltipRect.width / 2;
|
||||
const tcy = tooltipRect.top + tooltipRect.height / 2;
|
||||
|
||||
switch (placement) {
|
||||
case "top":
|
||||
return { x1: tcx, y1: tooltipRect.bottom, x2: tx, y2: targetRect.top };
|
||||
case "bottom":
|
||||
return { x1: tcx, y1: tooltipRect.top, x2: tx, y2: targetRect.bottom };
|
||||
case "left":
|
||||
return { x1: tooltipRect.right, y1: tcy, x2: targetRect.left, y2: ty };
|
||||
case "right":
|
||||
return { x1: tooltipRect.left, y1: tcy, x2: targetRect.right, y2: ty };
|
||||
default:
|
||||
return { x1: tcx, y1: tooltipRect.top, x2: tx, y2: targetRect.bottom };
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Placement engine ─────────────────────────────────────────
|
||||
|
||||
interface PlacementResult {
|
||||
left: number;
|
||||
top: number;
|
||||
actualPlacement: TooltipStep["placement"];
|
||||
}
|
||||
|
||||
/** Score a candidate — lower is better. Penalises covering the target or overflow. */
|
||||
function scorePlacement(
|
||||
left: number, top: number, tw: number, th: number,
|
||||
targetRect: DOMRect, vw: number, vh: number,
|
||||
): number {
|
||||
let score = 0;
|
||||
// Overflow penalty
|
||||
if (left < 0) score += Math.abs(left);
|
||||
if (top < 0) score += Math.abs(top);
|
||||
if (left + tw > vw) score += (left + tw - vw);
|
||||
if (top + th > vh) score += (top + th - vh);
|
||||
// Overlap with target penalty (avoid covering the highlighted element)
|
||||
const overlapX = Math.max(0, Math.min(left + tw, targetRect.right) - Math.max(left, targetRect.left));
|
||||
const overlapY = Math.max(0, Math.min(top + th, targetRect.bottom) - Math.max(top, targetRect.top));
|
||||
if (overlapX > 0 && overlapY > 0) score += overlapX * overlapY * 0.01;
|
||||
return score;
|
||||
}
|
||||
|
||||
function findBestPlacement(
|
||||
targetRect: DOMRect, tw: number, th: number,
|
||||
preferred: TooltipStep["placement"],
|
||||
): PlacementResult {
|
||||
const gap = 144;
|
||||
const vw = window.innerWidth;
|
||||
const vh = window.innerHeight;
|
||||
const all: Array<TooltipStep["placement"]> = [
|
||||
preferred ?? "bottom",
|
||||
...(["bottom", "top", "right", "left"] as const).filter((p) => p !== (preferred ?? "bottom")),
|
||||
];
|
||||
|
||||
let best: PlacementResult = { left: 0, top: 0, actualPlacement: "bottom" };
|
||||
let bestScore = Infinity;
|
||||
|
||||
for (const p of all) {
|
||||
let left = 0, top = 0;
|
||||
switch (p) {
|
||||
case "bottom":
|
||||
left = targetRect.left + targetRect.width / 2 - tw / 2;
|
||||
top = targetRect.bottom + gap;
|
||||
break;
|
||||
case "top":
|
||||
left = targetRect.left + targetRect.width / 2 - tw / 2;
|
||||
top = targetRect.top - th - gap;
|
||||
break;
|
||||
case "right":
|
||||
left = targetRect.right + gap;
|
||||
top = targetRect.top + targetRect.height / 2 - th / 2;
|
||||
break;
|
||||
case "left":
|
||||
left = targetRect.left - tw - gap;
|
||||
top = targetRect.top + targetRect.height / 2 - th / 2;
|
||||
break;
|
||||
}
|
||||
left = Math.max(12, Math.min(left, vw - tw - 12));
|
||||
top = Math.max(12, Math.min(top, vh - th - 12));
|
||||
|
||||
const s = scorePlacement(left, top, tw, th, targetRect, vw, vh);
|
||||
if (s < bestScore) {
|
||||
bestScore = s;
|
||||
best = { left, top, actualPlacement: p };
|
||||
}
|
||||
if (s === 0) break; // perfect
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────
|
||||
|
||||
export default function OnboardingTour({
|
||||
active, phase, stepIndex, onNext, onSkip, onDone,
|
||||
}: OnboardingTourProps) {
|
||||
const [pos, setPos] = useState<PlacementResult>({ left: 0, top: 0, actualPlacement: "bottom" });
|
||||
const [targetRect, setTargetRect] = useState<DOMRect | null>(null);
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [connector, setConnector] = useState<ConnectorPoints | null>(null);
|
||||
const tooltipRef = useRef<HTMLDivElement>(null);
|
||||
const prevPhaseRef = useRef(phase);
|
||||
const prevStepRef = useRef(stepIndex);
|
||||
|
||||
const phaseDef = PHASES[phase];
|
||||
const currentStep = phaseDef?.steps[stepIndex];
|
||||
const totalSteps = phaseDef?.steps.length ?? 0;
|
||||
const isLastStep = stepIndex >= totalSteps - 1;
|
||||
const isVideoLastStep = phase === "video" && isLastStep;
|
||||
|
||||
const stepChanged = prevPhaseRef.current !== phase || prevStepRef.current !== stepIndex;
|
||||
prevPhaseRef.current = phase;
|
||||
prevStepRef.current = stepIndex;
|
||||
|
||||
const recalc = useCallback(() => {
|
||||
if (!currentStep) return;
|
||||
const el = document.querySelector(`[data-onboarding="${currentStep.target}"]`) as HTMLElement | null;
|
||||
if (!el) return; // Will be retried by the polling loop
|
||||
const rect = el.getBoundingClientRect();
|
||||
setTargetRect(rect);
|
||||
|
||||
const tooltip = tooltipRef.current;
|
||||
if (!tooltip) return;
|
||||
const tr = tooltip.getBoundingClientRect();
|
||||
const best = findBestPlacement(rect, tr.width, tr.height, currentStep.placement);
|
||||
setPos(best);
|
||||
|
||||
// Recalculate tooltip rect after position update (use the same best pos)
|
||||
const virtualTooltipRect = new DOMRect(best.left, best.top, tr.width, tr.height);
|
||||
setConnector(calcConnector(virtualTooltipRect, rect, best.actualPlacement));
|
||||
}, [currentStep]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) { setVisible(false); return; }
|
||||
const t = setTimeout(() => { setVisible(true); recalc(); }, 120);
|
||||
return () => clearTimeout(t);
|
||||
}, [active, phase, stepIndex, recalc]);
|
||||
|
||||
// Reposition and retry when elements aren't ready
|
||||
useEffect(() => {
|
||||
if (!active || !visible) return;
|
||||
const h = () => recalc();
|
||||
window.addEventListener("resize", h);
|
||||
window.addEventListener("scroll", h, true);
|
||||
const obs = new MutationObserver(h);
|
||||
obs.observe(document.body, { childList: true, subtree: true, attributes: true });
|
||||
|
||||
// Polling retry: keep looking for the target element if not found yet
|
||||
let retryId: number | null = null;
|
||||
let attempts = 0;
|
||||
const poll = () => {
|
||||
recalc();
|
||||
attempts += 1;
|
||||
if (attempts < 40) retryId = requestAnimationFrame(poll);
|
||||
};
|
||||
// Start polling after a short delay
|
||||
const startTimer = setTimeout(() => { poll(); }, 200);
|
||||
return () => {
|
||||
window.removeEventListener("resize", h);
|
||||
window.removeEventListener("scroll", h, true);
|
||||
obs.disconnect();
|
||||
clearTimeout(startTimer);
|
||||
if (retryId !== null) cancelAnimationFrame(retryId);
|
||||
};
|
||||
}, [active, visible, recalc]);
|
||||
|
||||
// Animate in on step change
|
||||
useEffect(() => {
|
||||
if (!active || !visible || !stepChanged) return;
|
||||
const el = tooltipRef.current;
|
||||
if (!el) return;
|
||||
el.classList.remove("onboarding-tooltip--pop");
|
||||
void el.offsetWidth; // force reflow
|
||||
el.classList.add("onboarding-tooltip--pop");
|
||||
}, [active, visible, stepChanged, phase, stepIndex]);
|
||||
|
||||
if (!active || !currentStep) return null;
|
||||
|
||||
const connectorPath = connector
|
||||
? `M ${connector.x1} ${connector.y1} L ${connector.x2} ${connector.y2}`
|
||||
: "";
|
||||
|
||||
const arrowAngle = connector
|
||||
? Math.atan2(connector.y2 - connector.y1, connector.x2 - connector.x1) * (180 / Math.PI)
|
||||
: 0;
|
||||
|
||||
const clipPath = targetRect
|
||||
? `polygon(0% 0%, 0% 100%, ${targetRect.left - 6}px 100%, ${targetRect.left - 6}px ${targetRect.top - 6}px, ${targetRect.right + 6}px ${targetRect.top - 6}px, ${targetRect.right + 6}px ${targetRect.bottom + 6}px, ${targetRect.left - 6}px ${targetRect.bottom + 6}px, ${targetRect.left - 6}px 100%, 100% 100%, 100% 0%)`
|
||||
: "";
|
||||
|
||||
return createPortal(
|
||||
<div className={`onboarding-root${visible ? " is-visible" : ""}`} aria-label="新手引导教程">
|
||||
{/* Overlay */}
|
||||
<div className="onboarding-overlay" style={{ clipPath, WebkitClipPath: clipPath }} />
|
||||
|
||||
{/* Spotlight ring */}
|
||||
{targetRect && (
|
||||
<div
|
||||
className="onboarding-spotlight"
|
||||
style={{
|
||||
left: targetRect.left - 8,
|
||||
top: targetRect.top - 8,
|
||||
width: targetRect.width + 16,
|
||||
height: targetRect.height + 16,
|
||||
}}
|
||||
>
|
||||
{/* Animated pulse ring */}
|
||||
<div className="onboarding-spotlight__pulse" />
|
||||
<div className="onboarding-spotlight__pulse onboarding-spotlight__pulse--delay" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Connector SVG line */}
|
||||
{connector && (
|
||||
<svg className="onboarding-connector" aria-hidden="true">
|
||||
<defs>
|
||||
<linearGradient id="ob-conn-grad" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stopColor="var(--accent, #00ff88)" stopOpacity="0.2" />
|
||||
<stop offset="100%" stopColor="var(--accent, #00ff88)" stopOpacity="0.9" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
{/* Animated dash line */}
|
||||
<path
|
||||
d={connectorPath}
|
||||
fill="none"
|
||||
stroke="var(--accent, #00ff88)"
|
||||
strokeWidth="2"
|
||||
strokeDasharray="8 4"
|
||||
strokeLinecap="round"
|
||||
opacity="0.7"
|
||||
className="onboarding-connector__path"
|
||||
/>
|
||||
{/* Arrow at target end */}
|
||||
<circle
|
||||
cx={connector.x2}
|
||||
cy={connector.y2}
|
||||
r="5"
|
||||
fill="var(--accent, #00ff88)"
|
||||
className="onboarding-connector__dot"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
|
||||
{/* Tooltip card */}
|
||||
<div
|
||||
ref={tooltipRef}
|
||||
className={`onboarding-tooltip onboarding-tooltip--${pos.actualPlacement}`}
|
||||
style={{ left: pos.left, top: pos.top }}
|
||||
role="dialog"
|
||||
aria-label={currentStep.title}
|
||||
>
|
||||
{/* Arrow pointing toward target */}
|
||||
<div
|
||||
className={`onboarding-tooltip__arrow onboarding-tooltip__arrow--${pos.actualPlacement}`}
|
||||
style={{ transform: `rotate(${arrowAngle}deg)` }}
|
||||
/>
|
||||
|
||||
<div className="onboarding-tooltip__head">
|
||||
<span className="onboarding-tooltip__phase-badge">{phaseDef.label}</span>
|
||||
<span className="onboarding-tooltip__counter">
|
||||
{stepIndex + 1} / {totalSteps}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<strong className="onboarding-tooltip__title">{currentStep.title}</strong>
|
||||
<p className="onboarding-tooltip__desc">{currentStep.description}</p>
|
||||
|
||||
<div className="onboarding-tooltip__actions">
|
||||
<button type="button" className="onboarding-tooltip__btn onboarding-tooltip__btn--ghost" onClick={onDone}>
|
||||
<CloseOutlined /> 跳过教程
|
||||
</button>
|
||||
{stepIndex > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className="onboarding-tooltip__btn onboarding-tooltip__btn--ghost"
|
||||
onClick={() => onNext(phase, stepIndex - 1)}
|
||||
>
|
||||
<LeftOutlined /> 上一步
|
||||
</button>
|
||||
)}
|
||||
{isVideoLastStep ? (
|
||||
<button type="button" className="onboarding-tooltip__btn onboarding-tooltip__btn--primary" onClick={onDone}>
|
||||
开始使用 <RightOutlined />
|
||||
</button>
|
||||
) : isLastStep && phase !== "video" ? (
|
||||
<button type="button" className="onboarding-tooltip__btn onboarding-tooltip__btn--primary" onClick={() => onSkip(phase)}>
|
||||
{phase === "chat" ? "进入图像生成" : "进入视频生成"} <RightOutlined />
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" className="onboarding-tooltip__btn onboarding-tooltip__btn--primary" onClick={() => onNext(phase, stepIndex + 1)}>
|
||||
下一步 <RightOutlined />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom progress bar */}
|
||||
<div className="onboarding-progress" aria-hidden="true">
|
||||
{(["chat", "image", "video"] as TourPhaseId[]).map((p) => (
|
||||
<div key={p} className="onboarding-progress__phase">
|
||||
<div
|
||||
className={`onboarding-progress__dot${p === phase ? " is-active" : ""}${
|
||||
(["chat", "image", "video"].indexOf(p) < ["chat", "image", "video"].indexOf(phase)) ? " is-done" : ""
|
||||
}`}
|
||||
/>
|
||||
<span>{PHASES[p].label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useState, useCallback, type CSSProperties, type ImgHTMLAttributes } from "react";
|
||||
|
||||
const FALLBACK_SRC = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='80' height='80' viewBox='0 0 80 80'%3E%3Crect fill='%23222' width='80' height='80' rx='8'/%3E%3Cpath d='M28 52l8-12 6 8 12-16 10 20H28z' fill='%23444'/%3E%3Ccircle cx='32' cy='32' r='5' fill='%23444'/%3E%3C/svg%3E";
|
||||
|
||||
const baseStyle: CSSProperties = {
|
||||
transition: "opacity 0.3s ease",
|
||||
};
|
||||
|
||||
interface OptimizedImageProps extends ImgHTMLAttributes<HTMLImageElement> {
|
||||
fallbackSrc?: string;
|
||||
}
|
||||
|
||||
export default function OptimizedImage({
|
||||
src,
|
||||
alt = "",
|
||||
fallbackSrc = FALLBACK_SRC,
|
||||
style,
|
||||
onLoad,
|
||||
onError,
|
||||
...rest
|
||||
}: OptimizedImageProps) {
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [errored, setErrored] = useState(false);
|
||||
|
||||
const handleLoad = useCallback(
|
||||
(e: React.SyntheticEvent<HTMLImageElement>) => {
|
||||
setLoaded(true);
|
||||
onLoad?.(e);
|
||||
},
|
||||
[onLoad],
|
||||
);
|
||||
|
||||
const handleError = useCallback(
|
||||
(e: React.SyntheticEvent<HTMLImageElement>) => {
|
||||
if (!errored) {
|
||||
setErrored(true);
|
||||
(e.target as HTMLImageElement).src = fallbackSrc;
|
||||
}
|
||||
onError?.(e);
|
||||
},
|
||||
[errored, fallbackSrc, onError],
|
||||
);
|
||||
|
||||
return (
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
loading="lazy"
|
||||
style={{ ...baseStyle, opacity: loaded || errored ? 1 : 0, ...style }}
|
||||
onLoad={handleLoad}
|
||||
onError={handleError}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
|
||||
interface PageTransitionProps {
|
||||
viewKey: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const EXIT_DURATION_MS = 180;
|
||||
|
||||
const NAV_ORDER: string[] = [
|
||||
"home",
|
||||
"workbench",
|
||||
"ecommerce",
|
||||
"ecommerceTemplates",
|
||||
"sizeTemplate",
|
||||
"canvas",
|
||||
"scriptTokens",
|
||||
"tokenUsage",
|
||||
"community",
|
||||
"assets",
|
||||
"more",
|
||||
"imageWorkbench",
|
||||
"resolutionUpscale",
|
||||
"watermarkRemoval",
|
||||
"subtitleRemoval",
|
||||
"dialogGenerator",
|
||||
"digitalHuman",
|
||||
"avatarConsole",
|
||||
"characterMix",
|
||||
"agent",
|
||||
"login",
|
||||
"profile",
|
||||
"report",
|
||||
];
|
||||
|
||||
function getNavIndex(key: string): number {
|
||||
return NAV_ORDER.indexOf(key);
|
||||
}
|
||||
|
||||
export default function PageTransition({ viewKey, children }: PageTransitionProps) {
|
||||
const [displayedChildren, setDisplayedChildren] = useState(children);
|
||||
const [phase, setPhase] = useState<"idle" | "exit">("idle");
|
||||
const [exitDirection, setExitDirection] = useState<"forward" | "backward" | "neutral">("neutral");
|
||||
const prevKeyRef = useRef(viewKey);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
useEffect(() => {
|
||||
if (viewKey === prevKeyRef.current) {
|
||||
setDisplayedChildren(children);
|
||||
// Cancel any active exit animation — children updated but viewKey stable.
|
||||
setPhase("idle");
|
||||
return;
|
||||
}
|
||||
|
||||
const prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
if (prefersReducedMotion) {
|
||||
prevKeyRef.current = viewKey;
|
||||
setDisplayedChildren(children);
|
||||
setPhase("idle");
|
||||
return;
|
||||
}
|
||||
|
||||
const prevIndex = getNavIndex(prevKeyRef.current);
|
||||
const nextIndex = getNavIndex(viewKey);
|
||||
if (prevIndex < nextIndex) {
|
||||
setExitDirection("forward");
|
||||
} else if (prevIndex > nextIndex) {
|
||||
setExitDirection("backward");
|
||||
} else {
|
||||
setExitDirection("neutral");
|
||||
}
|
||||
prevKeyRef.current = viewKey;
|
||||
|
||||
setPhase("exit");
|
||||
timerRef.current = setTimeout(() => {
|
||||
setDisplayedChildren(children);
|
||||
setPhase("idle");
|
||||
}, EXIT_DURATION_MS);
|
||||
return () => clearTimeout(timerRef.current);
|
||||
}, [viewKey, children]);
|
||||
|
||||
const dirClass = exitDirection === "forward" ? " is-forward" : exitDirection === "backward" ? " is-backward" : "";
|
||||
|
||||
if (!displayedChildren) return null;
|
||||
|
||||
return (
|
||||
<div className={phase === "exit" ? `page-transition-wrap page-motion--exit${dirClass}` : "page-transition-wrap"}>
|
||||
{displayedChildren}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
import { CheckCircleOutlined, CloseOutlined, CrownOutlined, RocketOutlined } from "@ant-design/icons";
|
||||
import { useMemo, useState, type ReactNode } from "react";
|
||||
import "../../styles/components/recharge-modal.css";
|
||||
import { keyServerClient, type RechargeOrderResult } from "../../api/keyServerClient";
|
||||
import { toast } from "../toast/toastStore";
|
||||
|
||||
type RechargeAudience = "personal" | "enterprise";
|
||||
type PaymentMethod = "wechat" | "alipay" | "bank";
|
||||
|
||||
interface MembershipPlan {
|
||||
id: string;
|
||||
audience: RechargeAudience;
|
||||
name: string;
|
||||
subtitle: string;
|
||||
period: string;
|
||||
price: string;
|
||||
grant: string;
|
||||
comparisonLabel: string;
|
||||
badge?: string;
|
||||
icon: ReactNode;
|
||||
benefits: string[];
|
||||
}
|
||||
|
||||
const membershipPlans: MembershipPlan[] = [
|
||||
{
|
||||
id: "pro-month",
|
||||
audience: "personal",
|
||||
name: "专业版",
|
||||
subtitle: "Pro",
|
||||
period: "月付",
|
||||
price: "299 元 / 月",
|
||||
grant: "每月赠送 29900 积分,30 天有效",
|
||||
comparisonLabel: "专业版基础权益",
|
||||
icon: <CrownOutlined />,
|
||||
benefits: ["通用大模型全解锁", "积分与 API 消耗 9 折", "并发提升到 3 个", "去水印、插队加速、专属客服"],
|
||||
},
|
||||
{
|
||||
id: "pro-quarter",
|
||||
audience: "personal",
|
||||
name: "专业版",
|
||||
subtitle: "Pro",
|
||||
period: "季付",
|
||||
price: "897 元 / 季",
|
||||
grant: "季度合计 89700 积分,默认按月分摊",
|
||||
comparisonLabel: "相比月付新增",
|
||||
badge: "季度",
|
||||
icon: <CrownOutlined />,
|
||||
benefits: ["一次覆盖 3 个月使用周期", "每月延续 Pro 权益", "适合短期项目排期"],
|
||||
},
|
||||
{
|
||||
id: "pro-year",
|
||||
audience: "personal",
|
||||
name: "专业版",
|
||||
subtitle: "Pro",
|
||||
period: "年付",
|
||||
price: "1990 元 / 年",
|
||||
grant: "全年合计 199000 积分,默认按月分摊",
|
||||
comparisonLabel: "相比季付新增",
|
||||
badge: "年费优惠",
|
||||
icon: <CrownOutlined />,
|
||||
benefits: ["折合 10 个月费用", "前 100 名额外赠 20000 积分", "适合全年持续高频使用"],
|
||||
},
|
||||
{
|
||||
id: "enterprise-month",
|
||||
audience: "enterprise",
|
||||
name: "企业版",
|
||||
subtitle: "Enterprise",
|
||||
period: "月付",
|
||||
price: "499 元 / 月",
|
||||
grant: "每月赠送 49900 积分,30 天有效",
|
||||
comparisonLabel: "企业版基础权益",
|
||||
icon: <RocketOutlined />,
|
||||
benefits: ["企业私有模型与高性能模型", "默认 10 并发,可申请提升", "积分与 API 消耗 8 折", "用量报表与正式 API 权限"],
|
||||
},
|
||||
{
|
||||
id: "enterprise-quarter",
|
||||
audience: "enterprise",
|
||||
name: "企业版",
|
||||
subtitle: "Enterprise",
|
||||
period: "季付",
|
||||
price: "1497 元 / 季",
|
||||
grant: "季度合计 149700 积分,默认按月分摊",
|
||||
comparisonLabel: "相比月付新增",
|
||||
badge: "季度",
|
||||
icon: <RocketOutlined />,
|
||||
benefits: ["一次覆盖季度项目周期", "延续企业资源池与高并发", "适合阶段性团队投放"],
|
||||
},
|
||||
{
|
||||
id: "enterprise-year",
|
||||
audience: "enterprise",
|
||||
name: "企业版",
|
||||
subtitle: "Enterprise",
|
||||
period: "年付",
|
||||
price: "4990 元 / 年",
|
||||
grant: "全年合计 499000 积分,默认按月分摊",
|
||||
comparisonLabel: "相比季付新增",
|
||||
badge: "企业年费",
|
||||
icon: <RocketOutlined />,
|
||||
benefits: ["折合 10 个月费用", "前 100 名额外赠 100000 积分", "支持对公充值与子账户额度分配"],
|
||||
},
|
||||
];
|
||||
|
||||
const defaultSelectedPlanIds: Record<RechargeAudience, string> = {
|
||||
personal: "pro-month",
|
||||
enterprise: "enterprise-month",
|
||||
};
|
||||
|
||||
const rechargeRules = [
|
||||
"充值比例:固定 1 元 = 100 积分,平台可限时活动额外赠送积分",
|
||||
"有效期:充值积分到账起有效期 12 个月,系统按先进先出自动消耗",
|
||||
"退费规则:充值积分到账后不支持退换、折现,仅限平台内消费",
|
||||
];
|
||||
|
||||
const paymentMethods: Array<{ id: PaymentMethod; label: string; hint: string }> = [
|
||||
{ id: "wechat", label: "微信支付", hint: "生成支付链接或二维码" },
|
||||
{ id: "alipay", label: "支付宝", hint: "生成支付链接或二维码" },
|
||||
{ id: "bank", label: "对公转账", hint: "企业客户可联系客服确认" },
|
||||
];
|
||||
|
||||
export interface RechargeModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
currentBalance?: number;
|
||||
}
|
||||
|
||||
export function RechargeModal({ open, onClose, currentBalance }: RechargeModalProps) {
|
||||
const [activeAudience, setActiveAudience] = useState<RechargeAudience>("personal");
|
||||
const [selectedPlanIds, setSelectedPlanIds] = useState<Record<RechargeAudience, string>>(defaultSelectedPlanIds);
|
||||
const [paymentMethod, setPaymentMethod] = useState<PaymentMethod>("wechat");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [order, setOrder] = useState<RechargeOrderResult | null>(null);
|
||||
const visiblePlans = useMemo(() => membershipPlans.filter((plan) => plan.audience === activeAudience), [activeAudience]);
|
||||
const selectedPlanId = selectedPlanIds[activeAudience];
|
||||
const selectedPlan = membershipPlans.find((plan) => plan.id === selectedPlanId) ?? visiblePlans[0];
|
||||
|
||||
const handlePlanSelect = (plan: MembershipPlan) => {
|
||||
setSelectedPlanIds((current) => ({
|
||||
...current,
|
||||
[plan.audience]: plan.id,
|
||||
}));
|
||||
setOrder(null);
|
||||
};
|
||||
|
||||
const handleCreateOrder = async () => {
|
||||
if (!selectedPlan || submitting) return;
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const nextOrder = await keyServerClient.createRechargeOrder({ planId: selectedPlan.id, paymentMethod });
|
||||
setOrder(nextOrder);
|
||||
if (nextOrder.payUrl) {
|
||||
window.open(nextOrder.payUrl, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
toast.success("充值订单已创建");
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "订单创建失败,请联系客服处理。";
|
||||
toast.error(message);
|
||||
setOrder({
|
||||
orderId: `support-${Date.now()}`,
|
||||
status: "manual-review",
|
||||
message: "支付接口暂不可用,请通过页面联系方式联系客服完成充值。",
|
||||
});
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="recharge-modal" role="dialog" aria-modal="true" aria-labelledby="recharge-modal-title">
|
||||
<button type="button" className="recharge-modal__backdrop" onClick={onClose} aria-label="关闭充值弹窗" />
|
||||
<section className="recharge-modal__panel" aria-describedby="recharge-modal-desc">
|
||||
<div className="recharge-modal__promo" role="note">
|
||||
<strong>限时活动</strong>
|
||||
<span>年付套餐前 100 名额外赠送积分,季度套餐适合短期项目集中投放,充值积分有效期 12 个月。</span>
|
||||
</div>
|
||||
|
||||
<header className="recharge-modal__header">
|
||||
<div>
|
||||
<span className="recharge-modal__eyebrow">会员与积分</span>
|
||||
<h2 id="recharge-modal-title">积分充值</h2>
|
||||
<p id="recharge-modal-desc">按个人或企业场景选择版本,重点展示相对左侧版本的新增权益。</p>
|
||||
</div>
|
||||
{currentBalance !== undefined ? (
|
||||
<span className="recharge-modal__balance">当前余额:{(currentBalance / 100).toFixed(2)} 积分</span>
|
||||
) : null}
|
||||
<button type="button" className="recharge-modal__close" onClick={onClose} aria-label="关闭">
|
||||
<CloseOutlined />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="recharge-modal__audience-tabs" role="tablist" aria-label="充值对象">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activeAudience === "personal"}
|
||||
className={activeAudience === "personal" ? "is-active" : ""}
|
||||
onClick={() => setActiveAudience("personal")}
|
||||
>
|
||||
个人
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activeAudience === "enterprise"}
|
||||
className={activeAudience === "enterprise" ? "is-active" : ""}
|
||||
onClick={() => setActiveAudience("enterprise")}
|
||||
>
|
||||
企业
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="recharge-modal__grid" data-audience={activeAudience}>
|
||||
{visiblePlans.map((plan) => {
|
||||
const isSelected = plan.id === selectedPlanId;
|
||||
|
||||
return (
|
||||
<article
|
||||
key={plan.id}
|
||||
className={`recharge-modal__card recharge-modal__card--${plan.id}${isSelected ? " is-selected" : ""}`}
|
||||
>
|
||||
{plan.badge ? <span className="recharge-modal__badge">{plan.badge}</span> : null}
|
||||
<div className="recharge-modal__card-head">
|
||||
<span className="recharge-modal__card-icon">{plan.icon}</span>
|
||||
<div>
|
||||
<h3>{plan.name}</h3>
|
||||
<span>{plan.subtitle}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="recharge-modal__period">{plan.period}</span>
|
||||
<div className="recharge-modal__price">
|
||||
<strong>{plan.price}</strong>
|
||||
</div>
|
||||
<p className="recharge-modal__grant">{plan.grant}</p>
|
||||
<span className="recharge-modal__diff-label">{plan.comparisonLabel}</span>
|
||||
<ul className="recharge-modal__features">
|
||||
{plan.benefits.map((benefit) => (
|
||||
<li key={benefit}>
|
||||
<CheckCircleOutlined />
|
||||
<span>{benefit}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<button
|
||||
type="button"
|
||||
className={`recharge-modal__buy${isSelected ? " is-selected" : ""}`}
|
||||
aria-pressed={isSelected}
|
||||
onClick={() => handlePlanSelect(plan)}
|
||||
>
|
||||
{isSelected ? "当前方案" : "选择方案"}
|
||||
</button>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<footer className="recharge-modal__rules">
|
||||
<h3>积分充值规则</h3>
|
||||
<ol>
|
||||
{rechargeRules.map((rule) => (
|
||||
<li key={rule}>{rule}</li>
|
||||
))}
|
||||
</ol>
|
||||
</footer>
|
||||
|
||||
<section className="recharge-modal__checkout" aria-label="支付方式">
|
||||
<div>
|
||||
<span className="recharge-modal__checkout-eyebrow">支付确认</span>
|
||||
<h3>{selectedPlan.name} · {selectedPlan.period}</h3>
|
||||
<p>{selectedPlan.price},{selectedPlan.grant}</p>
|
||||
</div>
|
||||
<div className="recharge-modal__payment-methods" role="radiogroup" aria-label="选择支付方式">
|
||||
{paymentMethods.map((method) => (
|
||||
<button
|
||||
key={method.id}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={paymentMethod === method.id}
|
||||
className={paymentMethod === method.id ? "is-active" : ""}
|
||||
onClick={() => {
|
||||
setPaymentMethod(method.id);
|
||||
setOrder(null);
|
||||
}}
|
||||
>
|
||||
<strong>{method.label}</strong>
|
||||
<span>{method.hint}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button type="button" className="recharge-modal__pay" onClick={() => void handleCreateOrder()} disabled={submitting}>
|
||||
{submitting ? "创建订单中..." : "立即充值"}
|
||||
</button>
|
||||
{order ? (
|
||||
<div className="recharge-modal__order" role="status">
|
||||
<strong>订单号:{order.orderId}</strong>
|
||||
<span>状态:{order.status}</span>
|
||||
{order.qrCodeUrl ? <img src={order.qrCodeUrl} alt="支付二维码" /> : null}
|
||||
{order.payUrl ? <a href={order.payUrl} target="_blank" rel="noreferrer">打开支付链接</a> : null}
|
||||
<p>{order.message || "支付完成后积分将自动入账,如长时间未到账请联系客服。"}</p>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { ComponentType } from "react";
|
||||
import type { RechargeModalProps } from "./RechargeModal";
|
||||
|
||||
export type RechargeModalComponent = ComponentType<RechargeModalProps>;
|
||||
|
||||
let rechargeModalPromise: Promise<RechargeModalComponent> | null = null;
|
||||
|
||||
export function loadRechargeModal(): Promise<RechargeModalComponent> {
|
||||
if (!rechargeModalPromise) {
|
||||
rechargeModalPromise = import("./RechargeModal").then((module) => module.RechargeModal);
|
||||
}
|
||||
|
||||
return rechargeModalPromise;
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
import type { CSSProperties } from "react";
|
||||
|
||||
export type ShellIconName =
|
||||
| "arrow-down"
|
||||
| "arrow-left"
|
||||
| "arrow-up"
|
||||
| "bar-chart"
|
||||
| "bell"
|
||||
| "branches"
|
||||
| "check-circle"
|
||||
| "chevron-left"
|
||||
| "chevron-right"
|
||||
| "close-circle"
|
||||
| "copy"
|
||||
| "customer-service"
|
||||
| "delete"
|
||||
| "dislike"
|
||||
| "download"
|
||||
| "exclamation-circle"
|
||||
| "flag"
|
||||
| "file-text"
|
||||
| "folder"
|
||||
| "global"
|
||||
| "heart"
|
||||
| "home"
|
||||
| "info-circle"
|
||||
| "like"
|
||||
| "line-chart"
|
||||
| "lock"
|
||||
| "login"
|
||||
| "logout"
|
||||
| "loading"
|
||||
| "plus-circle"
|
||||
| "reload"
|
||||
| "robot"
|
||||
| "shopping"
|
||||
| "swap"
|
||||
| "team"
|
||||
| "thunderbolt"
|
||||
| "tool"
|
||||
| "upload"
|
||||
| "user"
|
||||
| "wallet"
|
||||
| "warning";
|
||||
|
||||
interface ShellIconProps {
|
||||
name: ShellIconName;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
function renderIcon(name: ShellIconName) {
|
||||
switch (name) {
|
||||
case "arrow-down":
|
||||
return <path d="M12 5v14m0 0 6-6m-6 6-6-6" />;
|
||||
case "arrow-left":
|
||||
return <path d="M19 12H5m0 0 6-6m-6 6 6 6" />;
|
||||
case "arrow-up":
|
||||
return <path d="M12 19V5m0 0 6 6m-6-6-6 6" />;
|
||||
case "bar-chart":
|
||||
return (
|
||||
<>
|
||||
<path d="M4 19V5" />
|
||||
<path d="M4 19h16" />
|
||||
<path d="M8 16v-5" />
|
||||
<path d="M12 16V8" />
|
||||
<path d="M16 16v-9" />
|
||||
</>
|
||||
);
|
||||
case "bell":
|
||||
return (
|
||||
<>
|
||||
<path d="M18 9a6 6 0 0 0-12 0c0 7-3 7-3 9h18c0-2-3-2-3-9" />
|
||||
<path d="M10 21h4" />
|
||||
</>
|
||||
);
|
||||
case "branches":
|
||||
return (
|
||||
<>
|
||||
<circle cx="6" cy="6" r="2" />
|
||||
<circle cx="18" cy="6" r="2" />
|
||||
<circle cx="12" cy="18" r="2" />
|
||||
<path d="M8 7.5 12 12l4-4.5" />
|
||||
<path d="M12 12v4" />
|
||||
</>
|
||||
);
|
||||
case "check-circle":
|
||||
return (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="m8 12 2.5 2.5L16 9" />
|
||||
</>
|
||||
);
|
||||
case "chevron-left":
|
||||
return <path d="m15 18-6-6 6-6" />;
|
||||
case "chevron-right":
|
||||
return <path d="m9 18 6-6-6-6" />;
|
||||
case "close-circle":
|
||||
return (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="m9 9 6 6m0-6-6 6" />
|
||||
</>
|
||||
);
|
||||
case "copy":
|
||||
return (
|
||||
<>
|
||||
<rect x="8" y="8" width="11" height="11" rx="2" />
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v1" />
|
||||
</>
|
||||
);
|
||||
case "customer-service":
|
||||
return (
|
||||
<>
|
||||
<path d="M4 13a8 8 0 0 1 16 0" />
|
||||
<path d="M5 13h3v5H5a2 2 0 0 1-2-2v-1a2 2 0 0 1 2-2Z" />
|
||||
<path d="M16 13h3a2 2 0 0 1 2 2v1a2 2 0 0 1-2 2h-3v-5Z" />
|
||||
<path d="M18 18c0 2-2 3-6 3" />
|
||||
</>
|
||||
);
|
||||
case "delete":
|
||||
return (
|
||||
<>
|
||||
<path d="M4 7h16" />
|
||||
<path d="M10 11v6" />
|
||||
<path d="M14 11v6" />
|
||||
<path d="M6 7l1 14h10l1-14" />
|
||||
<path d="M9 7V4h6v3" />
|
||||
</>
|
||||
);
|
||||
case "download":
|
||||
return (
|
||||
<>
|
||||
<path d="M12 4v11" />
|
||||
<path d="m7 10 5 5 5-5" />
|
||||
<path d="M5 20h14" />
|
||||
</>
|
||||
);
|
||||
case "dislike":
|
||||
return (
|
||||
<>
|
||||
<path d="M7 3v12" />
|
||||
<path d="M7 15h9l-1 5a2 2 0 0 1-3 1l-3-6H5a2 2 0 0 1-2-2V6a3 3 0 0 1 3-3h1" />
|
||||
<path d="M17 3h2a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2h-3" />
|
||||
</>
|
||||
);
|
||||
case "exclamation-circle":
|
||||
return (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="M12 7v6" />
|
||||
<path d="M12 17h.01" />
|
||||
</>
|
||||
);
|
||||
case "flag":
|
||||
return (
|
||||
<>
|
||||
<path d="M5 21V4" />
|
||||
<path d="M5 5h11l-1.5 4L16 13H5" />
|
||||
</>
|
||||
);
|
||||
case "file-text":
|
||||
return (
|
||||
<>
|
||||
<path d="M14 3H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9Z" />
|
||||
<path d="M14 3v6h6" />
|
||||
<path d="M8 13h8" />
|
||||
<path d="M8 17h6" />
|
||||
</>
|
||||
);
|
||||
case "folder":
|
||||
return <path d="M3 7h7l2 2h9v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7Z" />;
|
||||
case "global":
|
||||
return (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="M3 12h18" />
|
||||
<path d="M12 3c3 3 3 15 0 18" />
|
||||
<path d="M12 3c-3 3-3 15 0 18" />
|
||||
</>
|
||||
);
|
||||
case "heart":
|
||||
return <path d="M20 8.5c0 5-8 10.5-8 10.5S4 13.5 4 8.5A4.5 4.5 0 0 1 12 6a4.5 4.5 0 0 1 8 2.5Z" />;
|
||||
case "home":
|
||||
return (
|
||||
<>
|
||||
<path d="M3 11 12 4l9 7" />
|
||||
<path d="M5 10v10h14V10" />
|
||||
<path d="M10 20v-6h4v6" />
|
||||
</>
|
||||
);
|
||||
case "info-circle":
|
||||
return (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="M12 11v6" />
|
||||
<path d="M12 7h.01" />
|
||||
</>
|
||||
);
|
||||
case "like":
|
||||
return (
|
||||
<>
|
||||
<path d="M7 21V9" />
|
||||
<path d="M7 9h3l3-6a2 2 0 0 1 3 1l-1 5h4a2 2 0 0 1 2 2l-2 8a3 3 0 0 1-3 2H7" />
|
||||
<path d="M3 10h4v10H3z" />
|
||||
</>
|
||||
);
|
||||
case "line-chart":
|
||||
return (
|
||||
<>
|
||||
<path d="M4 19V5" />
|
||||
<path d="M4 19h16" />
|
||||
<path d="m7 15 4-4 3 3 5-7" />
|
||||
</>
|
||||
);
|
||||
case "lock":
|
||||
return (
|
||||
<>
|
||||
<rect x="5" y="10" width="14" height="10" rx="2" />
|
||||
<path d="M8 10V7a4 4 0 0 1 8 0v3" />
|
||||
</>
|
||||
);
|
||||
case "login":
|
||||
return (
|
||||
<>
|
||||
<path d="M14 4h5v16h-5" />
|
||||
<path d="M4 12h10" />
|
||||
<path d="m10 8 4 4-4 4" />
|
||||
</>
|
||||
);
|
||||
case "logout":
|
||||
return (
|
||||
<>
|
||||
<path d="M10 4H5v16h5" />
|
||||
<path d="M20 12H10" />
|
||||
<path d="m14 8-4 4 4 4" />
|
||||
</>
|
||||
);
|
||||
case "loading":
|
||||
return (
|
||||
<>
|
||||
<path d="M12 3a9 9 0 1 1-8 5" />
|
||||
<path d="M4 3v5h5" />
|
||||
</>
|
||||
);
|
||||
case "plus-circle":
|
||||
return (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="M12 8v8" />
|
||||
<path d="M8 12h8" />
|
||||
</>
|
||||
);
|
||||
case "reload":
|
||||
return (
|
||||
<>
|
||||
<path d="M20 12a8 8 0 1 1-2.3-5.7" />
|
||||
<path d="M20 4v6h-6" />
|
||||
</>
|
||||
);
|
||||
case "robot":
|
||||
return (
|
||||
<>
|
||||
<rect x="5" y="8" width="14" height="11" rx="3" />
|
||||
<path d="M12 8V4" />
|
||||
<path d="M8 13h.01" />
|
||||
<path d="M16 13h.01" />
|
||||
<path d="M9 17h6" />
|
||||
</>
|
||||
);
|
||||
case "shopping":
|
||||
return (
|
||||
<>
|
||||
<path d="M6 7h15l-2 8H8L6 7Z" />
|
||||
<path d="M6 7 5 4H2" />
|
||||
<circle cx="9" cy="20" r="1.5" />
|
||||
<circle cx="18" cy="20" r="1.5" />
|
||||
</>
|
||||
);
|
||||
case "swap":
|
||||
return (
|
||||
<>
|
||||
<path d="M7 7h13m0 0-4-4m4 4-4 4" />
|
||||
<path d="M17 17H4m0 0 4-4m-4 4 4 4" />
|
||||
</>
|
||||
);
|
||||
case "team":
|
||||
return (
|
||||
<>
|
||||
<circle cx="9" cy="8" r="3" />
|
||||
<path d="M3 20a6 6 0 0 1 12 0" />
|
||||
<path d="M16 11a3 3 0 1 0-1-5.8" />
|
||||
<path d="M17 20a5 5 0 0 0-3-4.6" />
|
||||
</>
|
||||
);
|
||||
case "thunderbolt":
|
||||
return <path d="M13 2 4 14h7l-1 8 10-13h-7l1-7Z" />;
|
||||
case "tool":
|
||||
return <path d="M14.5 5.5a5 5 0 0 0 4 6.5L9 21l-6-6 9-9.5a5 5 0 0 0 2.5 0Z" />;
|
||||
case "upload":
|
||||
return (
|
||||
<>
|
||||
<path d="M12 20V9" />
|
||||
<path d="m7 14 5-5 5 5" />
|
||||
<path d="M5 4h14" />
|
||||
</>
|
||||
);
|
||||
case "user":
|
||||
return (
|
||||
<>
|
||||
<circle cx="12" cy="8" r="4" />
|
||||
<path d="M4 21a8 8 0 0 1 16 0" />
|
||||
</>
|
||||
);
|
||||
case "wallet":
|
||||
return (
|
||||
<>
|
||||
<path d="M4 7h15a2 2 0 0 1 2 2v10H5a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h12" />
|
||||
<path d="M16 13h5" />
|
||||
<path d="M17 16h.01" />
|
||||
</>
|
||||
);
|
||||
case "warning":
|
||||
return (
|
||||
<>
|
||||
<path d="M12 3 2 20h20L12 3Z" />
|
||||
<path d="M12 9v5" />
|
||||
<path d="M12 17h.01" />
|
||||
</>
|
||||
);
|
||||
default:
|
||||
return <circle cx="12" cy="12" r="8" />;
|
||||
}
|
||||
}
|
||||
|
||||
export function ShellIcon({ name, className, style }: ShellIconProps) {
|
||||
return (
|
||||
<span className={["anticon", "shell-icon", className].filter(Boolean).join(" ")} style={style} aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||
{renderIcon(name)}
|
||||
</svg>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { CSSProperties } from "react";
|
||||
import "../styles/components/skeleton.css";
|
||||
|
||||
interface SkeletonProps {
|
||||
width?: string | number;
|
||||
height?: string | number;
|
||||
borderRadius?: string | number;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
export function Skeleton({ width, height = 16, borderRadius = 6, className = "", style }: SkeletonProps) {
|
||||
return (
|
||||
<div
|
||||
className={`skeleton-shimmer ${className}`}
|
||||
style={{ width, height, borderRadius, ...style }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function SkeletonCard({ className = "" }: { className?: string }) {
|
||||
return (
|
||||
<div className={`skeleton-card ${className}`} aria-hidden="true">
|
||||
<div className="skeleton-shimmer skeleton-card__thumb" />
|
||||
<div className="skeleton-card__body">
|
||||
<div className="skeleton-shimmer skeleton-card__title" />
|
||||
<div className="skeleton-shimmer skeleton-card__desc" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SkeletonList({ count = 4, className = "" }: { count?: number; className?: string }) {
|
||||
return (
|
||||
<div className={`skeleton-list ${className}`} role="status" aria-label="加载中">
|
||||
{Array.from({ length: count }, (_, i) => (
|
||||
<SkeletonCard key={i} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { ReactNode } from "react";
|
||||
import "../styles/pages/studio-layout.css";
|
||||
|
||||
interface StudioToolLayoutProps {
|
||||
toolstrip?: ReactNode;
|
||||
leftPanel?: ReactNode;
|
||||
canvas: ReactNode;
|
||||
rightPanel?: ReactNode;
|
||||
statusBar?: ReactNode;
|
||||
noLeft?: boolean;
|
||||
noRight?: boolean;
|
||||
noTop?: boolean;
|
||||
}
|
||||
|
||||
function StudioToolLayout({
|
||||
toolstrip,
|
||||
leftPanel,
|
||||
canvas,
|
||||
rightPanel,
|
||||
statusBar,
|
||||
noLeft,
|
||||
noRight,
|
||||
noTop,
|
||||
}: StudioToolLayoutProps) {
|
||||
return (
|
||||
<div
|
||||
className={`studio-tool-layout${noLeft ? " studio-tool-layout--no-left" : ""}${
|
||||
noRight ? " studio-tool-layout--no-right" : ""
|
||||
}${noTop ? " studio-tool-layout--no-top" : ""}`}
|
||||
>
|
||||
{!noTop ? <div className="studio-toolstrip">{toolstrip}</div> : null}
|
||||
{!noLeft ? <aside className="studio-panel studio-panel--left">{leftPanel}</aside> : null}
|
||||
<section className="studio-canvas">{canvas}</section>
|
||||
{!noRight ? <aside className="studio-panel studio-panel--right">{rightPanel}</aside> : null}
|
||||
<footer className="studio-status-bar">{statusBar}</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default StudioToolLayout;
|
||||
@@ -0,0 +1,22 @@
|
||||
interface TaskStatusBarProps {
|
||||
taskId?: string;
|
||||
status: string;
|
||||
progress: number;
|
||||
/** Label shown on the right side when idle (no active task). */
|
||||
idleLabel?: string;
|
||||
}
|
||||
|
||||
export default function TaskStatusBar({ taskId, status, progress, idleLabel }: TaskStatusBarProps) {
|
||||
const isActive = Boolean(taskId);
|
||||
const normalizedProgress = Math.max(0, Math.min(100, Math.trunc(Number(progress) || 0)));
|
||||
return (
|
||||
<footer className="image-workbench-status">
|
||||
{isActive && normalizedProgress > 0 && (
|
||||
<div className="image-workbench-progress" style={{ transform: `scaleX(${normalizedProgress / 100})` }} />
|
||||
)}
|
||||
<span>{isActive ? `#${taskId}` : "就绪"}</span>
|
||||
<p>{status}</p>
|
||||
<em>{isActive ? `${normalizedProgress}%` : idleLabel || ""}</em>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { CSSProperties, ReactNode } from "react";
|
||||
|
||||
interface WorkspacePageShellProps {
|
||||
title: string;
|
||||
headerRight?: ReactNode;
|
||||
fullWidth?: boolean;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function WorkspacePageShell({
|
||||
title,
|
||||
headerRight,
|
||||
fullWidth,
|
||||
className,
|
||||
style,
|
||||
children,
|
||||
}: WorkspacePageShellProps) {
|
||||
return (
|
||||
<section
|
||||
className={`workspace-page-shell${fullWidth ? " workspace-page-shell--full" : ""}${
|
||||
className ? ` ${className}` : ""
|
||||
}`}
|
||||
style={style}
|
||||
aria-label={title}
|
||||
>
|
||||
{headerRight ? <div className="workspace-page-shell__actions">{headerRight}</div> : null}
|
||||
<main className="workspace-page-shell__content">{children}</main>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default WorkspacePageShell;
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useToastState, type ToastItem } from "./toastStore";
|
||||
|
||||
const iconMap: Record<string, string> = {
|
||||
success: "✓",
|
||||
error: "✕",
|
||||
info: "ℹ",
|
||||
};
|
||||
|
||||
function ToastItemView({ item, onDismiss }: { item: ToastItem; onDismiss: (id: number) => void }) {
|
||||
return (
|
||||
<div className={`app-toast app-toast--${item.type}`} role="alert">
|
||||
<span className="app-toast__icon">{iconMap[item.type]}</span>
|
||||
<span className="app-toast__msg">{item.message}</span>
|
||||
{item.onRetry && (
|
||||
<button type="button" className="app-toast__retry" onClick={item.onRetry}>
|
||||
重试
|
||||
</button>
|
||||
)}
|
||||
<button type="button" className="app-toast__close" onClick={() => onDismiss(item.id)} aria-label="关闭">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ToastContainer() {
|
||||
const { items, dismiss } = useToastState();
|
||||
if (!items.length) return null;
|
||||
|
||||
return (
|
||||
<div className="app-toast-container" aria-live="polite">
|
||||
{items.map((item) => (
|
||||
<ToastItemView key={item.id} item={item} onDismiss={dismiss} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
export type ToastType = "success" | "error" | "info";
|
||||
|
||||
export interface ToastItem {
|
||||
id: number;
|
||||
type: ToastType;
|
||||
message: string;
|
||||
onRetry?: () => void;
|
||||
}
|
||||
|
||||
let toastId = 0;
|
||||
const listeners = new Set<(items: ToastItem[]) => void>();
|
||||
let queue: ToastItem[] = [];
|
||||
|
||||
function notify() {
|
||||
listeners.forEach((fn) => fn([...queue]));
|
||||
}
|
||||
|
||||
function dismiss(id: number) {
|
||||
queue = queue.filter((t) => t.id !== id);
|
||||
notify();
|
||||
}
|
||||
|
||||
export function toast(message: string, type: ToastType = "info", onRetry?: () => void) {
|
||||
const id = ++toastId;
|
||||
queue = [...queue.slice(-4), { id, type, message, onRetry }];
|
||||
notify();
|
||||
setTimeout(() => dismiss(id), type === "error" ? 5000 : 3000);
|
||||
return id;
|
||||
}
|
||||
|
||||
toast.success = (msg: string) => toast(msg, "success");
|
||||
toast.error = (msg: string, onRetry?: () => void) => toast(msg, "error", onRetry);
|
||||
toast.info = (msg: string) => toast(msg, "info");
|
||||
|
||||
export function useToastState() {
|
||||
const [items, setItems] = useState<ToastItem[]>([]);
|
||||
useEffect(() => {
|
||||
listeners.add(setItems);
|
||||
return () => { listeners.delete(setItems); };
|
||||
}, []);
|
||||
return { items, dismiss };
|
||||
}
|
||||
Reference in New Issue
Block a user