Initial ecommerce standalone package

This commit is contained in:
2026-06-10 14:06:16 +08:00
commit 3d98933e24
241 changed files with 135283 additions and 0 deletions
+110
View File
@@ -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;
+54
View File
@@ -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>
);
}
+541
View File
@@ -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;
+108
View File
@@ -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>
);
}
+349
View File
@@ -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;
+31
View File
@@ -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>
);
}
+99
View File
@@ -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>
);
}
+35
View File
@@ -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>
);
}
+152
View File
@@ -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;
+25
View File
@@ -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;
+157
View File
@@ -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;
+500
View File
@@ -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,
);
}
+55
View File
@@ -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}
/>
);
}
+91
View File
@@ -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;
}
+344
View File
@@ -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>
);
}
+42
View File
@@ -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>
);
}
+40
View File
@@ -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;
+22
View File
@@ -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>
);
}
+34
View File
@@ -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;
+37
View File
@@ -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>
);
}
+44
View File
@@ -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 };
}