Initial commit: OmniAI Web Frontend
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,427 @@
|
||||
import {
|
||||
ArrowDownOutlined,
|
||||
ArrowUpOutlined,
|
||||
CheckCircleOutlined,
|
||||
FlagOutlined,
|
||||
LoginOutlined,
|
||||
LogoutOutlined,
|
||||
PlusCircleOutlined,
|
||||
UserOutlined,
|
||||
WalletOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import type { ServerConnectionHealth } from "../api/serverConnection";
|
||||
import { canManageCommunityCases, canReviewCommunity } from "../features/community-review/communityPermissions";
|
||||
import type { WebNavItem, WebNotification, WebUsageSummary, WebUserSession, WebViewKey } from "../types";
|
||||
import NotificationCenter from "./NotificationCenter";
|
||||
import { RechargeModal } from "./RechargeModal/RechargeModal";
|
||||
|
||||
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 = "https://stringtest.oss-cn-hangzhou.aliyuncs.com/logo.png";
|
||||
|
||||
function formatBalance(cents: number): string {
|
||||
const value = Math.max(0, cents) / 100;
|
||||
return `${value.toFixed(2)} 积分`;
|
||||
}
|
||||
|
||||
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 [openSubmenuKey, setOpenSubmenuKey] = useState<WebViewKey | null>(null);
|
||||
const isAuthView = activeView === "login";
|
||||
const isImmersiveView = activeView === "agent" || activeView === "avatarConsole";
|
||||
const showFloatingNav = !isAuthView && !isImmersiveView && activeView !== "home";
|
||||
const toolSurfaceViews = [
|
||||
"workbench",
|
||||
"canvas",
|
||||
"more",
|
||||
"scriptTokens",
|
||||
"tokenUsage",
|
||||
"ecommerceTemplates",
|
||||
"sizeTemplate",
|
||||
"imageWorkbench",
|
||||
"resolutionUpscale",
|
||||
"digitalHuman",
|
||||
"avatarConsole",
|
||||
"characterMix",
|
||||
] as WebViewKey[];
|
||||
const showPageScrollActions = showFloatingNav && !toolSurfaceViews.includes(activeView);
|
||||
|
||||
const visibleNavItems = useMemo(
|
||||
() => {
|
||||
const orderedKeys: WebViewKey[] = [
|
||||
"workbench",
|
||||
"ecommerce",
|
||||
"sizeTemplate",
|
||||
"canvas",
|
||||
"scriptTokens",
|
||||
"tokenUsage",
|
||||
"community",
|
||||
"assets",
|
||||
"more",
|
||||
];
|
||||
return orderedKeys
|
||||
.map((key) => navItems.find((item) => item.key === key))
|
||||
.filter((item): item is WebNavItem => Boolean(item));
|
||||
},
|
||||
[navItems],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof document === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
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(() => {
|
||||
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 (!session) {
|
||||
setProfileOpen(false);
|
||||
}
|
||||
}, [session]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (submenuHideTimerRef.current) {
|
||||
window.clearTimeout(submenuHideTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
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 isPreviewSession = session?.source === "mock-fallback";
|
||||
const showCommunityReview = canReviewCommunity(session);
|
||||
const showCommunityCaseAdd = canManageCommunityCases(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" : ""}${
|
||||
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")}
|
||||
>
|
||||
<ArrowUpOutlined />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="floating-page-scroll-actions__button"
|
||||
title="到达页面底部"
|
||||
aria-label="到达页面底部"
|
||||
onClick={() => scrollActivePage("bottom")}
|
||||
>
|
||||
<ArrowDownOutlined />
|
||||
</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">
|
||||
{session && (
|
||||
<NotificationCenter
|
||||
items={notifications}
|
||||
onNavigate={(view, _targetId) => onSelectView(view)}
|
||||
onMarkRead={onMarkNotificationRead}
|
||||
onMarkAllRead={onMarkAllNotificationsRead}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
className="member-button"
|
||||
type="button"
|
||||
aria-label={`积分余额 ${displayedBalanceLabel}`}
|
||||
onClick={() => setRechargeOpen(true)}
|
||||
>
|
||||
<WalletOutlined />
|
||||
{displayedBalanceLabel}
|
||||
</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>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<LoginOutlined />
|
||||
<span>登录 / 注册</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
{session && profileOpen ? (
|
||||
<div 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>{import.meta.env.VITE_KEY_SERVER_URL || "使用预览数据"}</span>
|
||||
<button type="button" onClick={onLogout}>
|
||||
<LogoutOutlined />
|
||||
退出
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="profile-popover__center-btn"
|
||||
onClick={() => {
|
||||
setProfileOpen(false);
|
||||
onSelectView("login");
|
||||
}}
|
||||
>
|
||||
<UserOutlined />
|
||||
个人中心
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="profile-popover__report-btn"
|
||||
onClick={() => {
|
||||
setProfileOpen(false);
|
||||
onSelectView("report");
|
||||
}}
|
||||
>
|
||||
<FlagOutlined />
|
||||
投诉举报
|
||||
</button>
|
||||
{showCommunityReview ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="profile-popover__review-btn"
|
||||
onClick={() => {
|
||||
setProfileOpen(false);
|
||||
onSelectView("communityReview");
|
||||
}}
|
||||
>
|
||||
<CheckCircleOutlined />
|
||||
社区审核
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
{showCommunityCaseAdd ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="profile-popover__review-btn"
|
||||
onClick={() => {
|
||||
setProfileOpen(false);
|
||||
onSelectView("communityCaseAdd");
|
||||
}}
|
||||
>
|
||||
<PlusCircleOutlined />
|
||||
添加案例
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
) : null}
|
||||
<div className="web-shell__page">{children}</div>
|
||||
</main>
|
||||
</div>
|
||||
<RechargeModal open={rechargeOpen} onClose={() => setRechargeOpen(false)} currentBalance={displayedBalanceCents} />
|
||||
</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,98 @@
|
||||
import { useCallback, useRef, useState, type ReactNode } from "react";
|
||||
|
||||
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,34 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
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,167 @@
|
||||
import {
|
||||
BellOutlined,
|
||||
CheckCircleOutlined,
|
||||
CloseCircleOutlined,
|
||||
DeleteOutlined,
|
||||
DislikeOutlined,
|
||||
ExclamationCircleOutlined,
|
||||
LikeOutlined,
|
||||
LockOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { WebNotification, WebNotificationType, WebViewKey } from "../types";
|
||||
|
||||
const NOTIFICATION_ICONS: Record<WebNotificationType, React.ReactNode> = {
|
||||
task_completed: <CheckCircleOutlined style={{ color: "#10b981" }} />,
|
||||
task_failed: <CloseCircleOutlined style={{ color: "#ef4444" }} />,
|
||||
review_pending: <ExclamationCircleOutlined style={{ color: "#f59e0b" }} />,
|
||||
review_passed: <LikeOutlined style={{ color: "#10b981" }} />,
|
||||
review_rejected: <DislikeOutlined style={{ color: "#f59e0b" }} />,
|
||||
credits_low: <ExclamationCircleOutlined style={{ color: "#f59e0b" }} />,
|
||||
session_expired: <LockOutlined style={{ color: "#ef4444" }} />,
|
||||
info: <BellOutlined 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()); }}
|
||||
>
|
||||
<BellOutlined />
|
||||
{unreadCount > 0 && (
|
||||
<span className="notification-center__badge">{unreadCount > 99 ? "99+" : unreadCount}</span>
|
||||
)}
|
||||
</button>
|
||||
{open && (
|
||||
<div className="notification-center__panel">
|
||||
<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); }}>
|
||||
<DeleteOutlined /> 清空
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="notification-center__list">
|
||||
{notifications.length === 0 ? (
|
||||
<div className="notification-center__empty">
|
||||
<BellOutlined 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>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default NotificationCenter;
|
||||
@@ -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,35 @@
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
|
||||
interface PageTransitionProps {
|
||||
viewKey: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const EXIT_DURATION_MS = 180;
|
||||
|
||||
export default function PageTransition({ viewKey, children }: PageTransitionProps) {
|
||||
const [displayedChildren, setDisplayedChildren] = useState(children);
|
||||
const [phase, setPhase] = useState<"idle" | "exit">("idle");
|
||||
const prevKeyRef = useRef(viewKey);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
useEffect(() => {
|
||||
if (viewKey === prevKeyRef.current) {
|
||||
setDisplayedChildren(children);
|
||||
return;
|
||||
}
|
||||
prevKeyRef.current = viewKey;
|
||||
setPhase("exit");
|
||||
timerRef.current = setTimeout(() => {
|
||||
setDisplayedChildren(children);
|
||||
setPhase("idle");
|
||||
}, EXIT_DURATION_MS);
|
||||
return () => clearTimeout(timerRef.current);
|
||||
}, [viewKey, children]);
|
||||
|
||||
return (
|
||||
<div className={phase === "exit" ? "page-transition-wrap page-motion--exit" : "page-transition-wrap"}>
|
||||
{displayedChildren}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
import { CheckCircleOutlined, CloseOutlined, CrownOutlined, RocketOutlined } from "@ant-design/icons";
|
||||
import { useMemo, useState, type ReactNode } from "react";
|
||||
|
||||
type RechargeAudience = "personal" | "enterprise";
|
||||
|
||||
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: "每月赠送 10000 积分,30 天有效",
|
||||
comparisonLabel: "专业版基础权益",
|
||||
icon: <CrownOutlined />,
|
||||
benefits: ["通用大模型全解锁", "积分与 API 消耗 9 折", "并发提升到 3 个", "去水印、插队加速、专属客服"],
|
||||
},
|
||||
{
|
||||
id: "pro-quarter",
|
||||
audience: "personal",
|
||||
name: "专业版",
|
||||
subtitle: "Pro",
|
||||
period: "季付",
|
||||
price: "897 元 / 季",
|
||||
grant: "连续 3 个月按月发放 Pro 积分",
|
||||
comparisonLabel: "相比月付新增",
|
||||
badge: "季度",
|
||||
icon: <CrownOutlined />,
|
||||
benefits: ["一次覆盖 3 个月使用周期", "每月延续 Pro 权益", "适合短期项目排期"],
|
||||
},
|
||||
{
|
||||
id: "pro-year",
|
||||
audience: "personal",
|
||||
name: "专业版",
|
||||
subtitle: "Pro",
|
||||
period: "年付",
|
||||
price: "1990 元 / 年",
|
||||
grant: "全年合计 140000 积分,默认按月分摊",
|
||||
comparisonLabel: "相比季付新增",
|
||||
badge: "年费优惠",
|
||||
icon: <CrownOutlined />,
|
||||
benefits: ["折合 10 个月费用", "前 100 名额外赠 20000 积分", "适合全年持续高频使用"],
|
||||
},
|
||||
{
|
||||
id: "enterprise-month",
|
||||
audience: "enterprise",
|
||||
name: "企业版",
|
||||
subtitle: "Enterprise",
|
||||
period: "月付",
|
||||
price: "499 元 / 月",
|
||||
grant: "每月赠送 2000 积分,30 天有效",
|
||||
comparisonLabel: "企业版基础权益",
|
||||
icon: <RocketOutlined />,
|
||||
benefits: ["企业私有模型与高性能模型", "默认 10 并发,可申请提升", "积分与 API 消耗 8 折", "用量报表与正式 API 权限"],
|
||||
},
|
||||
{
|
||||
id: "enterprise-quarter",
|
||||
audience: "enterprise",
|
||||
name: "企业版",
|
||||
subtitle: "Enterprise",
|
||||
period: "季付",
|
||||
price: "1497 元 / 季",
|
||||
grant: "连续 3 个月按月发放企业版积分",
|
||||
comparisonLabel: "相比月付新增",
|
||||
badge: "季度",
|
||||
icon: <RocketOutlined />,
|
||||
benefits: ["一次覆盖季度项目周期", "延续企业资源池与高并发", "适合阶段性团队投放"],
|
||||
},
|
||||
{
|
||||
id: "enterprise-year",
|
||||
audience: "enterprise",
|
||||
name: "企业版",
|
||||
subtitle: "Enterprise",
|
||||
period: "年付",
|
||||
price: "4990 元 / 年",
|
||||
grant: "全年合计 340000 积分,默认按月分摊",
|
||||
comparisonLabel: "相比季付新增",
|
||||
badge: "企业年费",
|
||||
icon: <RocketOutlined />,
|
||||
benefits: ["折合 10 个月费用", "前 100 名额外赠 100000 积分", "支持对公充值与子账户额度分配"],
|
||||
},
|
||||
];
|
||||
|
||||
const defaultSelectedPlanIds: Record<RechargeAudience, string> = {
|
||||
personal: "pro-month",
|
||||
enterprise: "enterprise-month",
|
||||
};
|
||||
|
||||
const rechargeRules = [
|
||||
"充值比例:固定 1 元 = 100 积分,平台可限时活动额外赠送积分",
|
||||
"有效期:充值积分到账起有效期 12 个月,系统按先进先出自动消耗",
|
||||
"退费规则:充值积分到账后不支持退换、折现,仅限平台内消费",
|
||||
];
|
||||
|
||||
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 visiblePlans = useMemo(() => membershipPlans.filter((plan) => plan.audience === activeAudience), [activeAudience]);
|
||||
const selectedPlanId = selectedPlanIds[activeAudience];
|
||||
|
||||
const handlePlanSelect = (plan: MembershipPlan) => {
|
||||
setSelectedPlanIds((current) => ({
|
||||
...current,
|
||||
[plan.audience]: plan.id,
|
||||
}));
|
||||
};
|
||||
|
||||
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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { CSSProperties } from "react";
|
||||
|
||||
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,39 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
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