feat(web): 用户工作台 - 产品化壳层、内容库布局与积分入口
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -4,7 +4,7 @@ import { cn } from "@/lib/utils";
|
||||
|
||||
/** 资产库卡片的共同外壳;页面只负责业务动作和卡片内的信息层级。 */
|
||||
export function AssetLibraryCard({ children, className, selected = false }: { children: ReactNode; className?: string; selected?: boolean }) {
|
||||
return <article className={cn("library-card library-card-surface asset-library-card group", selected && "is-selected", className)}>{children}</article>;
|
||||
return <article className={cn("product-collection-card library-card library-card-surface asset-library-card group", selected && "is-selected", className)}>{children}</article>;
|
||||
}
|
||||
|
||||
export function AssetLibraryCardMedia({ children, className }: { children: ReactNode; className?: string }) {
|
||||
|
||||
@@ -17,8 +17,6 @@ const featureNames: Record<FeatureKey, string> = {
|
||||
pluginCenterEnabled: "插件中心",
|
||||
};
|
||||
|
||||
let featureAvailabilityCheckedOnce = false;
|
||||
|
||||
export function RequireFeature({ feature, children }: { feature: FeatureKey; children: ReactNode }) {
|
||||
const navigate = useNavigate();
|
||||
const user = useUserStore((state) => state.user);
|
||||
@@ -28,8 +26,6 @@ export function RequireFeature({ feature, children }: { feature: FeatureKey; chi
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (featureAvailabilityCheckedOnce) return;
|
||||
featureAvailabilityCheckedOnce = true;
|
||||
let cancelled = false;
|
||||
setError("");
|
||||
refreshFeatureAvailability()
|
||||
@@ -42,7 +38,7 @@ export function RequireFeature({ feature, children }: { feature: FeatureKey; chi
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
}, [feature, user?.id]);
|
||||
|
||||
if (checking) return <WorkspacePage><WorkspaceLoadingState label="正在确认功能状态" detail={featureNames[feature]} rows={3} /></WorkspacePage>;
|
||||
if (error) return <WorkspacePage><WorkspaceErrorState title="无法确认功能状态" description={error} actionLabel="返回创作台" onRetry={() => navigate("/", { replace: true })} /></WorkspacePage>;
|
||||
|
||||
@@ -8,6 +8,7 @@ type CachedResourceImageProps = Omit<ImgHTMLAttributes<HTMLImageElement>, "src">
|
||||
storageKey?: string;
|
||||
src?: string;
|
||||
fallback?: ReactNode;
|
||||
loadingFallback?: ReactNode;
|
||||
eager?: boolean;
|
||||
};
|
||||
|
||||
@@ -15,7 +16,7 @@ type CachedResourceImageProps = Omit<ImgHTMLAttributes<HTMLImageElement>, "src">
|
||||
* 资源图片优先读取按用户隔离的本地 Blob 缓存,避免刷新后再次从对象存储下载。
|
||||
* 本地 image: 类型的 storageKey 也会自动从 LocalForage 恢复有效的 Object URL。
|
||||
*/
|
||||
export function CachedResourceImage({ storageKey, src = "", fallback = null, eager = false, onError, ...props }: CachedResourceImageProps) {
|
||||
export function CachedResourceImage({ storageKey, src = "", fallback = null, loadingFallback = fallback, eager = false, onError, ...props }: CachedResourceImageProps) {
|
||||
const remoteResource = Boolean(resourceIdFromStorageKey(storageKey));
|
||||
const localImageResource = Boolean(storageKey && storageKey.startsWith("image:"));
|
||||
const targetRef = useRef<HTMLSpanElement>(null);
|
||||
@@ -61,11 +62,15 @@ export function CachedResourceImage({ storageKey, src = "", fallback = null, eag
|
||||
const resolve = cacheResourceObjectUrl(storageKey);
|
||||
void resolve
|
||||
.then((url) => {
|
||||
if (!cancelled) setCachedSrc(url || src);
|
||||
if (!cancelled) {
|
||||
setCachedSrc(url || src);
|
||||
setCacheFailed(!url && !src);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setCacheFailed(true);
|
||||
// 缓存读取失败时仍允许原始地址加载,真正的解码失败再显示占位。
|
||||
setCacheFailed(!src);
|
||||
setCachedSrc(src);
|
||||
}
|
||||
});
|
||||
@@ -120,7 +125,7 @@ export function CachedResourceImage({ storageKey, src = "", fallback = null, eag
|
||||
}
|
||||
return (
|
||||
<span ref={targetRef} className="cached-resource-image-shell">
|
||||
{cachedSrc && !(cacheFailed && !src) ? <img {...props} src={cachedSrc} onError={handleImgError} /> : fallback}
|
||||
{cachedSrc && !cacheFailed ? <img {...props} src={cachedSrc} onError={handleImgError} /> : cacheFailed ? fallback : loadingFallback}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -54,22 +54,22 @@ export function CanvasFolderCard({ project, projectName, onClick, onPrefetch, op
|
||||
if (event.target !== event.currentTarget) return;
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
if (!editing) onClick();
|
||||
if (!editing && !opening) onClick();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<article className={cn("canvas-folder-card", selected && "is-selected", editing && "is-editing", opening && "is-opening")} onPointerEnter={onPrefetch} onPointerDown={onPrefetch} onFocusCapture={onPrefetch}>
|
||||
<div className="canvas-folder-open" role="button" tabIndex={0} aria-label={`打开画布 ${project.title}`} aria-busy={opening} onClick={() => !editing && !opening && onClick()} onKeyDown={handleOpenKeyDown}>
|
||||
<div className="canvas-folder-preview" aria-hidden="true">
|
||||
<article className={cn("product-collection-card canvas-collection-card", selected && "is-selected", editing && "is-editing", opening && "is-opening")} onPointerEnter={onPrefetch} onPointerDown={onPrefetch} onFocusCapture={onPrefetch}>
|
||||
<div className="canvas-collection-open" role="button" tabIndex={0} aria-label={`打开画布 ${project.title}`} aria-busy={opening} onClick={() => !editing && !opening && onClick()} onKeyDown={handleOpenKeyDown}>
|
||||
<div className="canvas-collection-preview" aria-hidden="true">
|
||||
<ProjectPreview project={{ id: project.id, nodes: project.previewNodes }} preferLatestImage />
|
||||
{opening ? <div className="canvas-folder-opening"><LoaderCircle className="size-5 animate-spin" /><span>正在打开</span></div> : null}
|
||||
{opening ? <div className="canvas-collection-opening"><LoaderCircle className="size-5 animate-spin" /><span>正在打开</span></div> : null}
|
||||
</div>
|
||||
<div className="canvas-folder-body">
|
||||
<div className="canvas-folder-heading-row">
|
||||
<div className="canvas-collection-body">
|
||||
<div className="canvas-collection-heading-row">
|
||||
{editing ? (
|
||||
<Input
|
||||
className="canvas-folder-title-input"
|
||||
className="canvas-collection-title-input"
|
||||
value={editingTitle}
|
||||
onChange={(event) => setEditingTitle(event.target.value)}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
@@ -81,22 +81,19 @@ export function CanvasFolderCard({ project, projectName, onClick, onPrefetch, op
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<span className="canvas-folder-title">{project.title}</span>
|
||||
<span className="canvas-collection-title">{project.title}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="canvas-folder-meta">
|
||||
<span className="canvas-folder-meta-item">{projectName ? `所属项目:${projectName}` : "自由画布"}</span>
|
||||
<span className="canvas-folder-meta-separator" aria-hidden="true">·</span>
|
||||
<span className="canvas-folder-meta-item">{project.nodeCount} 节点</span>
|
||||
</div>
|
||||
<div className="canvas-folder-dates">
|
||||
<span><small>创建时间</small><time dateTime={project.createdAt}>{formatCanvasDate(project.createdAt)}</time></span>
|
||||
<span><small>最后更新</small><time dateTime={project.updatedAt}>{formatCanvasDate(project.updatedAt)}</time></span>
|
||||
<div className="canvas-collection-meta">
|
||||
<span className="canvas-collection-meta-item">{projectName ? `所属项目:${projectName}` : "自由画布"}</span>
|
||||
<span className="canvas-collection-meta-separator" aria-hidden="true">·</span>
|
||||
<span className="canvas-collection-meta-item">{project.nodeCount} 节点</span>
|
||||
</div>
|
||||
<time className="canvas-collection-updated" dateTime={project.updatedAt} title={`创建于 ${formatCanvasDate(project.createdAt)}`}>{formatCanvasDate(project.updatedAt)} 更新</time>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span className={cn("canvas-folder-select", selected && "is-visible")} onClick={(event) => event.stopPropagation()}>
|
||||
<span className={cn("canvas-collection-select", selected && "is-visible")} onClick={(event) => event.stopPropagation()}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected}
|
||||
@@ -105,11 +102,11 @@ export function CanvasFolderCard({ project, projectName, onClick, onPrefetch, op
|
||||
/>
|
||||
</span>
|
||||
|
||||
<div className="canvas-folder-actions" onClick={(event) => event.stopPropagation()}>
|
||||
<div className="canvas-collection-actions" onClick={(event) => event.stopPropagation()}>
|
||||
{!editing ? (
|
||||
<button
|
||||
type="button"
|
||||
className="canvas-folder-rename"
|
||||
className="product-icon-button canvas-collection-rename"
|
||||
aria-label={`重命名 ${project.title}`}
|
||||
title="重命名"
|
||||
onClick={(event) => {
|
||||
@@ -133,7 +130,7 @@ export function CanvasFolderCard({ project, projectName, onClick, onPrefetch, op
|
||||
],
|
||||
}}
|
||||
>
|
||||
<button type="button" className="canvas-folder-more" aria-label={`${project.title} 画布操作`} title="更多操作" onClick={(event) => event.stopPropagation()}>
|
||||
<button type="button" className="product-icon-button canvas-collection-more" aria-label={`${project.title} 画布操作`} title="更多操作" onClick={(event) => event.stopPropagation()}>
|
||||
<MoreHorizontal />
|
||||
</button>
|
||||
</Dropdown>
|
||||
|
||||
@@ -10,6 +10,7 @@ import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
|
||||
import { resourceFileUrl, resourceIdFromStorageKey } from "@/services/api/resources";
|
||||
import { resolveBackendApiUrl } from "@/stores/use-config-store";
|
||||
import { CachedResourceImage } from "@/components/cached-resource-image";
|
||||
import { MediaPlaceholder } from "@/components/ui/product/media-placeholder";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useSyncProgressStore } from "@/stores/use-sync-progress-store";
|
||||
|
||||
@@ -154,7 +155,7 @@ export function ProjectPreview({ project, preferLatestImage = false }: { project
|
||||
<Video className="size-8" aria-label={media.node.title || "项目视频"} />
|
||||
</div>
|
||||
) : (
|
||||
<CachedResourceImage storageKey={media.storageKey} src={media.url} alt={media.node.title || "项目图片"} loading="lazy" decoding="async" className="size-full min-h-0 object-cover" />
|
||||
<CachedResourceImage storageKey={media.storageKey} src={media.url} alt={media.node.title || "项目图片"} loading="lazy" decoding="async" className="size-full min-h-0 object-cover" fallback={<MediaPlaceholder failed />} loadingFallback={<MediaPlaceholder label="正在读取封面" />} />
|
||||
)}
|
||||
</div>
|
||||
) : !project.nodes.length ? (
|
||||
|
||||
@@ -102,11 +102,11 @@ export function CanvasProjectSidebar({ projectId, detail, onAddChapter, onLocate
|
||||
|
||||
if (collapsed) {
|
||||
return (
|
||||
<aside className="relative z-[var(--z-panel)] hidden w-11 shrink-0 flex-col items-center border-r border-border bg-background/94 py-2 backdrop-blur-xl lg:flex">
|
||||
<aside className="canvas-project-sidebar is-collapsed relative z-[var(--z-panel)] hidden w-11 shrink-0 flex-col items-center border-r border-border bg-background/94 py-2 backdrop-blur-xl lg:flex">
|
||||
<button type="button" className="grid size-7 place-items-center rounded-md text-foreground/55 hover:bg-surface-hover" title="展开项目侧栏" aria-label="展开项目侧栏" onClick={() => setCollapsed(false)}>
|
||||
<ChevronRight className="size-4" />
|
||||
</button>
|
||||
<Link to={`/projects/${projectId}/canvases`} className="mt-2 grid size-7 place-items-center rounded-md text-foreground/55 hover:bg-surface-hover" title="返回项目画布列表">
|
||||
<Link to={`/projects/${projectId}/canvases`} className="canvas-project-sidebar-back-link mt-2 grid size-7 place-items-center rounded-md text-foreground/55 hover:bg-surface-hover" title="返回项目画布列表">
|
||||
<FolderKanban className="size-4" />
|
||||
</Link>
|
||||
</aside>
|
||||
@@ -114,8 +114,8 @@ export function CanvasProjectSidebar({ projectId, detail, onAddChapter, onLocate
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="relative z-[var(--z-panel)] hidden w-[var(--canvas-sidebar-width)] shrink-0 flex-col border-r border-border bg-background/94 backdrop-blur-xl lg:flex">
|
||||
<header className="flex h-12 shrink-0 items-center justify-between gap-2 border-b border-border px-2.5">
|
||||
<aside className="canvas-project-sidebar relative z-[var(--z-panel)] hidden w-[var(--canvas-sidebar-width)] shrink-0 flex-col border-r border-border bg-background/94 backdrop-blur-xl lg:flex">
|
||||
<header className="canvas-project-sidebar-header flex h-12 shrink-0 items-center justify-between gap-2 border-b border-border px-2.5">
|
||||
<Link to={`/projects/${projectId}/canvases`} className="flex min-w-0 items-center gap-2 text-xs font-semibold" title="返回项目画布列表">
|
||||
<FolderKanban className="size-3.5 shrink-0" />
|
||||
<span className="truncate">{projectDetail?.project.name || "项目空间"}</span>
|
||||
@@ -125,7 +125,7 @@ export function CanvasProjectSidebar({ projectId, detail, onAddChapter, onLocate
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<section className="shrink-0 border-b border-border/70 p-2">
|
||||
<section className="canvas-project-sidebar-style shrink-0 border-b border-border/70 p-2">
|
||||
<div className="mb-1.5 flex h-5 items-center justify-between px-1 text-[var(--fs-label)] font-medium text-foreground/48">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Palette className="size-3.5" />
|
||||
@@ -139,7 +139,7 @@ export function CanvasProjectSidebar({ projectId, detail, onAddChapter, onLocate
|
||||
type="button"
|
||||
disabled={!style}
|
||||
onClick={onLocateStyle}
|
||||
className="group flex h-11 w-full items-center gap-2 rounded-md px-1.5 text-left hover:bg-surface-hover disabled:cursor-default disabled:hover:bg-transparent"
|
||||
className="canvas-project-sidebar-style-button group flex h-11 w-full items-center gap-2 rounded-md px-1.5 text-left hover:bg-surface-hover disabled:cursor-default disabled:hover:bg-transparent"
|
||||
title={style ? "定位画布中的项目画风节点" : "项目尚未设置画风"}
|
||||
>
|
||||
{style ? (
|
||||
@@ -157,8 +157,8 @@ export function CanvasProjectSidebar({ projectId, detail, onAddChapter, onLocate
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section className="flex min-h-0 flex-1 flex-col">
|
||||
<div className="flex h-9 shrink-0 items-center justify-between px-3">
|
||||
<section className="canvas-project-sidebar-chapters flex min-h-0 flex-1 flex-col">
|
||||
<div className="canvas-project-sidebar-section-heading flex h-9 shrink-0 items-center justify-between px-3">
|
||||
<span className="flex items-center gap-1.5 text-[var(--fs-label)] font-medium text-foreground/48">
|
||||
<BookOpenText className="size-3.5" />
|
||||
剧情章节 <span className="tabular-nums text-foreground/32">{orderedUnits.length.toLocaleString("zh-CN")}</span>
|
||||
@@ -170,7 +170,7 @@ export function CanvasProjectSidebar({ projectId, detail, onAddChapter, onLocate
|
||||
) : null}
|
||||
</div>
|
||||
<div className="shrink-0 px-2 pb-2">
|
||||
<label className="flex h-8 items-center gap-1.5 rounded-md border border-border/75 bg-foreground/[.025] px-2 focus-within:border-[var(--workspace-accent)] focus-within:ring-2 focus-within:ring-[var(--workspace-accent-soft)]">
|
||||
<label className="canvas-project-sidebar-search flex h-8 items-center gap-1.5 rounded-md border border-border/75 bg-foreground/[.025] px-2 focus-within:border-[var(--workspace-accent)] focus-within:ring-2 focus-within:ring-[var(--workspace-accent-soft)]">
|
||||
<Search className="size-3.5 shrink-0 text-foreground/32" />
|
||||
<input
|
||||
value={query}
|
||||
@@ -191,7 +191,7 @@ export function CanvasProjectSidebar({ projectId, detail, onAddChapter, onLocate
|
||||
{deferredQuery ? <div className="mt-1 px-0.5 text-[var(--fs-micro)] tabular-nums text-foreground/35">找到 {visibleUnits.length.toLocaleString("zh-CN")} 章</div> : null}
|
||||
</div>
|
||||
|
||||
<div ref={scrollRef} className="thin-scrollbar min-h-0 flex-1 overflow-y-auto overscroll-contain px-1.5" aria-label="项目章节列表">
|
||||
<div ref={scrollRef} className="canvas-project-sidebar-chapter-scroll thin-scrollbar min-h-0 flex-1 overflow-y-auto overscroll-contain px-1.5" aria-label="项目章节列表">
|
||||
{visibleUnits.length ? (
|
||||
<div className="relative w-full" style={{ height: chapterVirtualizer.getTotalSize() }}>
|
||||
{chapterVirtualizer.getVirtualItems().map((virtualItem) => {
|
||||
@@ -208,7 +208,7 @@ export function CanvasProjectSidebar({ projectId, detail, onAddChapter, onLocate
|
||||
event.dataTransfer.setData(CANVAS_PROJECT_CHAPTER_DND_TYPE, JSON.stringify(payload));
|
||||
event.dataTransfer.effectAllowed = "copy";
|
||||
}}
|
||||
className={`group flex h-9 cursor-grab items-center gap-1 rounded-md px-1 text-xs active:cursor-grabbing ${active ? "bg-surface-active" : "hover:bg-surface-hover"}`}
|
||||
className={`canvas-project-sidebar-chapter-row group flex h-9 cursor-grab items-center gap-1 rounded-md px-1 text-xs active:cursor-grabbing ${active ? "bg-surface-active" : "hover:bg-surface-hover"}`}
|
||||
>
|
||||
<GripVertical className="size-3.5 shrink-0 text-foreground/22" />
|
||||
<button
|
||||
@@ -241,8 +241,8 @@ export function CanvasProjectSidebar({ projectId, detail, onAddChapter, onLocate
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="shrink-0 border-t border-border/70 p-2">
|
||||
<button type="button" onClick={onOpenAssets} className="flex h-9 w-full items-center gap-2 rounded-md border border-border/45 px-2 text-xs text-foreground/65 hover:border-[var(--workspace-accent)] hover:bg-[var(--workspace-accent-soft)]">
|
||||
<section className="canvas-project-sidebar-assets shrink-0 border-t border-border/70 p-2">
|
||||
<button type="button" onClick={onOpenAssets} className="canvas-project-sidebar-assets-button flex h-9 w-full items-center gap-2 rounded-md border border-border/45 px-2 text-xs text-foreground/65 hover:border-[var(--workspace-accent)] hover:bg-[var(--workspace-accent-soft)]">
|
||||
<Images className="size-3.5 text-foreground/42" />
|
||||
<span className="flex-1 text-left">引用项目资产</span>
|
||||
<span className="rounded bg-foreground/[.06] px-1.5 py-0.5 text-[var(--fs-micro)] tabular-nums text-foreground/45">{mediaAssetCount.toLocaleString("zh-CN")}</span>
|
||||
|
||||
@@ -42,6 +42,11 @@ export function AppWorkspaceShell({ children }: { children: ReactNode }) {
|
||||
writeWorkspaceSidebarCollapsed(false);
|
||||
};
|
||||
|
||||
const collapseDesktopSidebar = () => {
|
||||
setDesktopSidebarCollapsed(true);
|
||||
writeWorkspaceSidebarCollapsed(true);
|
||||
};
|
||||
|
||||
const handleNavClick = () => {
|
||||
if (isMobileViewport()) setMobileSidebarExpanded(false);
|
||||
};
|
||||
@@ -89,6 +94,7 @@ export function AppWorkspaceShell({ children }: { children: ReactNode }) {
|
||||
onNavigate={handleNavClick}
|
||||
onOpenSearch={() => setPaletteOpen(true)}
|
||||
onExpand={expandDesktopSidebar}
|
||||
onCollapse={collapseDesktopSidebar}
|
||||
/>
|
||||
</aside>
|
||||
) : null}
|
||||
|
||||
20
web/src/components/layout/collection-toolbar.tsx
Normal file
20
web/src/components/layout/collection-toolbar.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Button } from "antd";
|
||||
import { RotateCcw } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
/** 内容库的浏览控件,不复用后台表格的筛选外框。 */
|
||||
export function CollectionToolbar({ children, trailing, active, onReset, label = "搜索与筛选" }: {
|
||||
children: ReactNode;
|
||||
trailing?: ReactNode;
|
||||
active?: boolean;
|
||||
onReset?: () => void;
|
||||
label?: string;
|
||||
}) {
|
||||
return <section className="collection-toolbar" aria-label={label}>
|
||||
<div className="collection-toolbar-controls">{children}</div>
|
||||
<div className="collection-toolbar-actions">
|
||||
{active && onReset ? <Button type="text" icon={<RotateCcw />} onClick={onReset}>重置</Button> : null}
|
||||
{trailing}
|
||||
</div>
|
||||
</section>;
|
||||
}
|
||||
22
web/src/components/layout/user-avatar.tsx
Normal file
22
web/src/components/layout/user-avatar.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { CircleUserRound } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { LocalUser } from "@/stores/use-user-store";
|
||||
|
||||
export function UserAvatar({ user, className }: { user: LocalUser; className?: string }) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
const avatarUrl = /^https?:\/\//i.test(user.avatarUrl || "") ? user.avatarUrl : "";
|
||||
|
||||
useEffect(() => setFailed(false), [avatarUrl]);
|
||||
|
||||
// 结构保持 button > span > svg:占位图标自动复用顶栏图标按钮的 18px/1.8 描边与配色合同;默认不套圆角容器,hover 由按钮背景反馈。
|
||||
return (
|
||||
<span className={cn("grid shrink-0 place-items-center overflow-hidden", className)}>
|
||||
{avatarUrl && !failed ? (
|
||||
<img src={avatarUrl} alt="" referrerPolicy="no-referrer" className="size-full object-cover" onError={() => setFailed(true)} />
|
||||
) : (
|
||||
<CircleUserRound className="size-full" aria-hidden />
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
25
web/src/components/layout/workspace-account-card.css
Normal file
25
web/src/components/layout/workspace-account-card.css
Normal file
@@ -0,0 +1,25 @@
|
||||
.workspace-account-popover .ant-popover-container { padding: 6px; border: 0 !important; border-radius: 20px; background: var(--user-surface-raised) !important; box-shadow: none !important; }
|
||||
.workspace-account-popover .workspace-topbar-account-menu { width: auto; min-width: 0; padding: 0; }
|
||||
.workspace-account-card { width: min(304px, calc(100vw - 48px)); color: var(--user-ink); padding: 12px; }
|
||||
.workspace-account-card-identity { display: flex; align-items: center; gap: 12px; padding: 4px 0 16px; }
|
||||
.workspace-account-card-avatar { width: 42px; height: 42px; border-radius: 14px; background: var(--user-accent-soft); color: var(--user-accent); padding: 8px; }
|
||||
.workspace-account-card-identity > div { flex: 1; min-width: 0; }
|
||||
.workspace-account-card-identity strong, .workspace-account-card-identity span { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.workspace-account-card-identity strong { font-size: 15px; line-height: 24px; }
|
||||
.workspace-account-card-identity span { color: var(--user-ink-muted); font-size: 12px; }
|
||||
.workspace-account-card-identity em { font-style: normal; font-size: 10px; padding: 4px 8px; border-radius: 999px; background: var(--user-surface-muted); color: var(--user-ink-muted); }
|
||||
.workspace-account-card-wallet { display: flex; align-items: flex-end; justify-content: space-between; gap: 12px; padding: 16px; margin-bottom: 12px; border-radius: 16px; background: linear-gradient(120deg, var(--user-accent-soft), color-mix(in srgb, var(--product-accent-blue) 10%, var(--user-surface))); }
|
||||
.workspace-account-card-balance > span { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--user-ink-muted); }
|
||||
.workspace-account-card-balance strong { display: block; margin-top: 4px; font-size: 26px; font-weight: 650; letter-spacing: -.03em; font-variant-numeric: tabular-nums; }
|
||||
.workspace-account-card-wallet > button { display: inline-flex; align-items: center; gap: 4px; font-size: 12px; color: var(--user-accent); white-space: nowrap; padding: 6px 0; }
|
||||
.workspace-account-card svg { width: 17px; height: 17px; flex-shrink: 0; stroke-width: 1.7; }
|
||||
.workspace-account-card-avatar svg { width: 100%; height: 100%; }
|
||||
.workspace-account-card-actions { display: grid; gap: 4px; }
|
||||
.workspace-account-card-actions > a, .workspace-account-card-actions > button { display: flex; align-items: center; gap: 10px; min-height: 40px; height: auto; width: 100%; padding: 10px; border: 0; border-radius: 12px; font-size: 13px; text-align: left; justify-content: flex-start; transition: background var(--user-motion), transform var(--user-motion); }
|
||||
.workspace-account-card-actions > a { color: var(--user-ink); }
|
||||
.workspace-account-card-actions > a > span { flex: 1; }
|
||||
.workspace-account-card-actions > a > svg:last-child { color: var(--user-ink-soft); width: 14px; }
|
||||
.workspace-account-card-actions > a:hover { background: var(--user-surface-muted); }
|
||||
.workspace-account-card-actions > a:active { transform: scale(.98); }
|
||||
.workspace-account-card-actions > a:focus-visible, .workspace-account-card-wallet > button:focus-visible { outline: 2px solid var(--user-accent); outline-offset: 2px; }
|
||||
@media (prefers-reduced-motion: reduce) { .workspace-account-card-actions > a:active { transform: none; } }
|
||||
33
web/src/components/layout/workspace-account-card.tsx
Normal file
33
web/src/components/layout/workspace-account-card.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Button } from "antd";
|
||||
import { ArrowUpRight, Coins, LogOut, RefreshCw, Settings, ShieldCheck } from "lucide-react";
|
||||
import { Link } from "react-router";
|
||||
import { useWalletBalance } from "@/hooks/use-wallet-balance";
|
||||
import { useWorkspaceLogout } from "@/hooks/use-workspace-logout";
|
||||
import { useUserStore } from "@/stores/use-user-store";
|
||||
import { UserAvatar } from "./user-avatar";
|
||||
import "./workspace-account-card.css";
|
||||
|
||||
/** 同一账户卡片用于顶部和侧栏;余额与退出均复用真实服务。 */
|
||||
export function WorkspaceAccountCard({ onWallet, onNavigate }: { onWallet: () => void; onNavigate: () => void }) {
|
||||
const user = useUserStore((state) => state.user);
|
||||
const creditsEnabled = useUserStore((state) => state.features.creditsEnabled);
|
||||
const { availableMicrocredits, refreshing, refresh } = useWalletBalance(user?.id, creditsEnabled);
|
||||
const { handleLogout, loggingOut } = useWorkspaceLogout();
|
||||
if (!user) return null;
|
||||
return <section className="workspace-account-card" aria-label="我的账户">
|
||||
<header className="workspace-account-card-identity">
|
||||
<UserAvatar user={user} className="workspace-account-card-avatar" />
|
||||
<div><strong>{user.displayName || user.username}</strong><span>@{user.username}</span></div>
|
||||
<em>{user.role === "admin" ? "管理员" : "创作者"}</em>
|
||||
</header>
|
||||
{creditsEnabled ? <div className="workspace-account-card-wallet">
|
||||
<div className="workspace-account-card-balance"><span><Coins />可用积分</span><strong>{availableMicrocredits === null ? "—" : (availableMicrocredits / 1_000_000).toLocaleString("zh-CN", { maximumFractionDigits: 2 })}</strong></div>
|
||||
{availableMicrocredits === null ? <Button size="small" loading={refreshing} icon={<RefreshCw />} onClick={() => void refresh()}>刷新余额</Button> : <button type="button" onClick={onWallet}>充值 / 兑换<ArrowUpRight /></button>}
|
||||
</div> : null}
|
||||
<nav className="workspace-account-card-actions" aria-label="账户操作">
|
||||
<Link to="/settings" onClick={onNavigate}><Settings /><span>账户与设置</span><ArrowUpRight /></Link>
|
||||
{user.role === "admin" ? <Link to="/admin" onClick={onNavigate}><ShieldCheck /><span>管理员后台</span><ArrowUpRight /></Link> : null}
|
||||
<Button danger type="text" icon={<LogOut />} loading={loggingOut} onClick={() => void handleLogout()}>退出登录</Button>
|
||||
</nav>
|
||||
</section>;
|
||||
}
|
||||
@@ -1,56 +1,46 @@
|
||||
import { Popover } from "antd";
|
||||
import { Switch } from "@/components/ui/base/switch";
|
||||
import { CircleUserRound, LogIn, Moon, Sun } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { LogIn, Moon, Sun } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
|
||||
import { AppChangelogButton } from "@/components/layout/app-changelog-modal";
|
||||
import { IdentityProviderBadge } from "@/components/layout/identity-provider-badge";
|
||||
import { useWalletBalance } from "@/hooks/use-wallet-balance";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { WorkspaceAccountCard } from "./workspace-account-card";
|
||||
import { WorkspaceWalletModal } from "./workspace-wallet-modal";
|
||||
import { UserAvatar } from "./user-avatar";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
import { useUserStore, type LocalUser } from "@/stores/use-user-store";
|
||||
import { useUserStore } from "@/stores/use-user-store";
|
||||
|
||||
/** 顶部栏头像账户菜单:只承载账户信息、版本和显示偏好;管理与退出入口统一放在侧栏。 */
|
||||
/** 顶部与侧栏复用同一账户卡片;顶部额外保留版本和主题偏好。 */
|
||||
export function WorkspaceAccountMenu() {
|
||||
const theme = useThemeStore((state) => state.theme);
|
||||
const setTheme = useThemeStore((state) => state.setTheme);
|
||||
const user = useUserStore((state) => state.user);
|
||||
const hydrated = useUserStore((state) => state.hydrated);
|
||||
const creditsEnabled = useUserStore((state) => state.features.creditsEnabled);
|
||||
const { availableMicrocredits } = useWalletBalance(user?.id, creditsEnabled);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
|
||||
const balance = availableMicrocredits === null
|
||||
? "--"
|
||||
: (availableMicrocredits / 1_000_000).toLocaleString("zh-CN", { maximumFractionDigits: 2 });
|
||||
const [walletOpen, setWalletOpen] = useState(false);
|
||||
|
||||
if (!hydrated) {
|
||||
return <span className="size-9 animate-pulse rounded-[var(--r-md)] bg-foreground/[.06]" aria-hidden />;
|
||||
}
|
||||
|
||||
return user ? (
|
||||
<Popover
|
||||
<><Popover
|
||||
trigger="click"
|
||||
placement="bottomRight"
|
||||
rootClassName="workspace-account-popover"
|
||||
open={menuOpen}
|
||||
onOpenChange={setMenuOpen}
|
||||
content={(
|
||||
<div className="w-56 py-0.5">
|
||||
<div className="flex items-center gap-3 px-1 pb-3">
|
||||
<UserAvatar user={user} className="size-8" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 items-center gap-1.5"><span className="truncate text-sm font-medium">{user.displayName || user.username}</span><IdentityProviderBadge user={user} /></div>
|
||||
{creditsEnabled ? <div className="mt-0.5 truncate text-[var(--fs-label)] tabular-nums text-foreground/45">可用 {balance} 积分</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="workspace-topbar-account-menu">
|
||||
<WorkspaceAccountCard onNavigate={() => setMenuOpen(false)} onWallet={() => { setMenuOpen(false); setWalletOpen(true); }} />
|
||||
|
||||
<div className="border-t border-border/35 py-2">
|
||||
<div className="workspace-topbar-account-section">
|
||||
<AppChangelogButton className="flex h-8 w-full items-center gap-2 rounded px-2 text-[var(--fs-label)] text-foreground/58 hover:bg-surface-hover hover:text-foreground [&_svg]:size-3.5" showLabel showVersion versionClassName="ml-auto text-[var(--fs-micro)] tabular-nums text-foreground/32" />
|
||||
</div>
|
||||
|
||||
<div className="flex h-10 items-center px-2">
|
||||
<div className="workspace-topbar-account-theme">
|
||||
{theme === "dark" ? <Moon className="size-3.5 text-foreground/45" /> : <Sun className="size-3.5 text-foreground/45" />}
|
||||
<span className="ml-2 flex-1 text-xs text-foreground/65">深色模式</span>
|
||||
<Switch size="sm" checked={theme === "dark"} onChange={(checked) => setTheme(checked ? "dark" : "light")} aria-label="深色模式" />
|
||||
@@ -61,28 +51,10 @@ export function WorkspaceAccountMenu() {
|
||||
<button type="button" className="app-workspace-topbar-icon-button app-workspace-account-trigger" aria-label="账户菜单" title={user.displayName || user.username}>
|
||||
<UserAvatar user={user} className="size-6" />
|
||||
</button>
|
||||
</Popover>
|
||||
</Popover>{creditsEnabled ? <WorkspaceWalletModal open={walletOpen} onClose={() => setWalletOpen(false)} /> : null}</>
|
||||
) : (
|
||||
<Link to="/login" className="app-workspace-topbar-icon-button" aria-label="登录" title="登录">
|
||||
<LogIn />
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function UserAvatar({ user, className }: { user: LocalUser; className?: string }) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
const avatarUrl = /^https?:\/\//i.test(user.avatarUrl || "") ? user.avatarUrl : "";
|
||||
|
||||
useEffect(() => setFailed(false), [avatarUrl]);
|
||||
|
||||
// 结构保持 button > span > svg:占位图标自动复用顶栏图标按钮的 18px/1.8 描边与配色合同;默认不套圆角容器,hover 由按钮背景反馈。
|
||||
return (
|
||||
<span className={cn("grid shrink-0 place-items-center overflow-hidden", className)}>
|
||||
{avatarUrl && !failed ? (
|
||||
<img src={avatarUrl} alt="" referrerPolicy="no-referrer" className="size-full object-cover" onError={() => setFailed(true)} />
|
||||
) : (
|
||||
<CircleUserRound className="size-full" aria-hidden />
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
import { ChevronDown, ChevronRight, Home, Infinity as InfinityIcon, LogOut, PanelLeftOpen, Plus, Search, ShieldCheck } from "lucide-react";
|
||||
import { Popover } from "antd";
|
||||
import { Bell, ChevronDown, ChevronRight, CircleUserRound, History as HistoryIcon, Infinity as InfinityIcon, PanelLeftClose, PanelLeftOpen, Plus } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState, type ComponentType, type CSSProperties } from "react";
|
||||
import { Link, useLocation, useNavigate, useSearchParams } from "react-router";
|
||||
|
||||
import { BrandLogoFrame } from "@/components/brand/brand-logo";
|
||||
import { Kbd } from "@/components/ui/base/kbd";
|
||||
import { navigationTools, type NavigationToolSlug } from "@/constant/navigation-tools";
|
||||
import { useWalletBalance } from "@/hooks/use-wallet-balance";
|
||||
import { useWorkspaceLogout } from "@/hooks/use-workspace-logout";
|
||||
import { SystemAnnouncementCenter } from "@/components/layout/system-announcement-center";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { preloadWorkspaceRoute } from "@/lib/workspace-route-modules";
|
||||
import { useUserStore, type FeatureAvailability } from "@/stores/use-user-store";
|
||||
import { useAppearanceStore } from "@/stores/use-appearance-store";
|
||||
import { WorkspaceAccountCard } from "./workspace-account-card";
|
||||
import { WorkspaceWalletModal } from "./workspace-wallet-modal";
|
||||
|
||||
export type WorkspaceNavItem = {
|
||||
id: string;
|
||||
@@ -28,64 +31,65 @@ type WorkspaceNavGroup = {
|
||||
items: WorkspaceNavItem[];
|
||||
};
|
||||
|
||||
/** 设置页分区子项,沿用 /settings?section=<key> 路由合同。 */
|
||||
const SETTINGS_SECTIONS: Array<{ key: string; label: string }> = [
|
||||
{ key: "channels", label: "自定义渠道" },
|
||||
{ key: "models", label: "模型选择" },
|
||||
{ key: "preferences", label: "生成偏好" },
|
||||
{ key: "prompts", label: "提示词偏好" },
|
||||
{ key: "storage", label: "我的对象存储" },
|
||||
];
|
||||
|
||||
function toolItem(slug: NavigationToolSlug, to: string): WorkspaceNavItem {
|
||||
const tool = navigationTools.find((item) => item.slug === slug);
|
||||
return { id: slug, title: tool?.label ?? slug, icon: tool?.icon, to };
|
||||
}
|
||||
|
||||
function buildNav(features: FeatureAvailability, balance: string, isAdmin: boolean): { groups: WorkspaceNavGroup[]; footer: WorkspaceNavItem[] } {
|
||||
function buildNav(features: FeatureAvailability, isAdmin: boolean): { groups: WorkspaceNavGroup[]; footer: WorkspaceNavItem[] } {
|
||||
const groups: WorkspaceNavGroup[] = [
|
||||
{
|
||||
items: [
|
||||
{ id: "home", title: "首页", icon: Home, to: "/" },
|
||||
toolItem("projects", "/projects"),
|
||||
toolItem("canvas", "/canvas"),
|
||||
...(features.taskCenterEnabled ? [toolItem("tasks", "/tasks")] : []),
|
||||
toolItem("assets", "/assets"),
|
||||
{ ...toolItem("create", "/"), id: "home", title: "创作" },
|
||||
{ ...toolItem("projects", "/projects"), title: "短剧 Agent" },
|
||||
{ ...toolItem("canvas", "/canvas"), title: "自由画布" },
|
||||
],
|
||||
},
|
||||
{
|
||||
heading: "工作台管理",
|
||||
items: [toolItem("skills", "/skills"), ...(features.pluginCenterEnabled || isAdmin ? [toolItem("plugins", "/plugins")] : []), ...(features.creditsEnabled ? [{ ...toolItem("wallet", "/wallet"), badge: balance }] : [])],
|
||||
heading: "资源与工具",
|
||||
items: [{ ...toolItem("assets", "/assets"), title: "资产" }, { ...toolItem("skills", "/skills"), title: "技能" }, ...(features.pluginCenterEnabled || isAdmin ? [{ ...toolItem("plugins", "/plugins"), title: "插件" }] : [])],
|
||||
},
|
||||
...(features.taskCenterEnabled ? [{ items: [{ ...toolItem("tasks", "/tasks"), title: "创作历史", icon: HistoryIcon }] }] : []),
|
||||
];
|
||||
|
||||
const settingsSections = SETTINGS_SECTIONS.filter((section) => section.key !== "channels" || features.customChannelsEnabled);
|
||||
const footer: WorkspaceNavItem[] = [
|
||||
...(isAdmin ? [{ id: "admin", title: "管理员后台", icon: ShieldCheck, to: "/admin" }] : []),
|
||||
{
|
||||
...toolItem("settings", "/settings"),
|
||||
children: settingsSections.map((section) => ({
|
||||
id: `settings:${section.key}`,
|
||||
title: section.label,
|
||||
to: `/settings?section=${section.key}`,
|
||||
})),
|
||||
},
|
||||
{ id: "logout", title: "退出登录", icon: LogOut, action: "logout" },
|
||||
];
|
||||
|
||||
return { groups, footer };
|
||||
// 管理、设置和退出登录不再占据参考站式侧栏底部,而是通过用户卡片菜单进入。
|
||||
// 路由和写操作仍保留,避免把用户端导航变成无法访问的装饰。
|
||||
return { groups, footer: [] };
|
||||
}
|
||||
|
||||
function WorkspaceSwitcher({ collapsed, onNavigate, onExpand }: { collapsed: boolean; onNavigate: () => void; onExpand: () => void }) {
|
||||
const appearance = useAppearanceStore((state) => state.appearance);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
function WorkspaceSidebarProfile({ collapsed, user }: { collapsed: boolean; user: NonNullable<ReturnType<typeof useUserStore.getState>["user"]> | null }) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [walletOpen, setWalletOpen] = useState(false);
|
||||
const creditsEnabled = useUserStore((state) => state.features.creditsEnabled);
|
||||
const avatarUrl = /^https?:\/\//i.test(user?.avatarUrl || "") ? user?.avatarUrl : "";
|
||||
const profileName = user?.displayName || user?.username || "未登录";
|
||||
|
||||
const go = (to: string) => {
|
||||
setIsOpen(false);
|
||||
onNavigate();
|
||||
navigate(to);
|
||||
};
|
||||
useEffect(() => setFailed(false), [avatarUrl]);
|
||||
|
||||
if (!user) {
|
||||
return <Link to="/login" className={cn("app-workspace-sidebar-profile", collapsed && "is-collapsed")} aria-label="登录" title="登录"><CircleUserRound className="size-5" /><span>登录</span></Link>;
|
||||
}
|
||||
|
||||
const avatar = avatarUrl && !failed ? <img src={avatarUrl} alt="" referrerPolicy="no-referrer" onError={() => setFailed(true)} /> : <CircleUserRound aria-hidden />;
|
||||
const content = <WorkspaceAccountCard onNavigate={() => setMenuOpen(false)} onWallet={() => { setMenuOpen(false); setWalletOpen(true); }} />;
|
||||
|
||||
return (
|
||||
<div className={cn("app-workspace-sidebar-profile-row", collapsed && "is-collapsed")}>
|
||||
<Popover open={menuOpen} onOpenChange={setMenuOpen} trigger="click" placement="topLeft" rootClassName="workspace-account-popover" content={content}>
|
||||
<button type="button" className={cn("app-workspace-sidebar-profile", collapsed && "is-collapsed")} aria-label="打开账户菜单" title={profileName}>
|
||||
<span className="app-workspace-sidebar-profile-avatar">{avatar}</span>
|
||||
{!collapsed ? <span className="app-workspace-sidebar-profile-copy"><strong>{profileName}</strong><span>创作工作台</span></span> : null}
|
||||
</button>
|
||||
</Popover>
|
||||
{creditsEnabled ? <WorkspaceWalletModal open={walletOpen} onClose={() => setWalletOpen(false)} /> : null}
|
||||
{!collapsed ? <SystemAnnouncementCenter userId={user.id} className="app-workspace-sidebar-notification" /> : <span className="app-workspace-sidebar-notification-spacer" aria-hidden />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkspaceSwitcher({ collapsed, onNavigate, onExpand, onCollapse }: { collapsed: boolean; onNavigate: () => void; onExpand: () => void; onCollapse: () => void }) {
|
||||
const appearance = useAppearanceStore((state) => state.appearance);
|
||||
|
||||
if (collapsed) {
|
||||
return (
|
||||
@@ -98,52 +102,19 @@ function WorkspaceSwitcher({ collapsed, onNavigate, onExpand }: { collapsed: boo
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative shrink-0 px-3 pt-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen((open) => !open)}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={isOpen}
|
||||
className="group flex w-full items-center justify-between rounded-[var(--r-sm)] px-2 py-2 text-left transition-colors select-none hover:bg-surface-hover"
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-3">
|
||||
<BrandLogoFrame className="app-workspace-brand-mark grid size-8 shrink-0 place-items-center rounded-[var(--r-sm)] shadow-sm" logoClassName="size-5 object-contain" alt="" fallback={<InfinityIcon className="size-4" strokeWidth={2} />} />
|
||||
<div className="app-workspace-sidebar-brand-row relative shrink-0 px-3 pt-3">
|
||||
<Link to="/" onClick={onNavigate} className="app-workspace-sidebar-brand-button group" aria-label={`${appearance.brandName}首页`}>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<BrandLogoFrame className="app-workspace-brand-mark grid size-8 shrink-0 place-items-center rounded-[var(--r-sm)] shadow-sm" logoClassName="size-5 object-contain" alt="" fallback={<InfinityIcon className="size-4" strokeWidth={2.2} />} />
|
||||
<span className="flex min-w-0 flex-col">
|
||||
<span className="app-workspace-brand-wordmark truncate text-[var(--fs-body)] leading-none font-medium">{appearance.brandName}</span>
|
||||
<span className="mt-1 truncate text-[var(--fs-label)] leading-none text-foreground/42">创作工作台</span>
|
||||
<span className="app-workspace-brand-wordmark truncate text-[var(--fs-body)] leading-none font-semibold">{appearance.brandName}</span>
|
||||
<span className="mt-1 truncate text-[var(--fs-label)] leading-none text-foreground/60">创作工作台</span>
|
||||
</span>
|
||||
</span>
|
||||
<ChevronDown className="size-4 shrink-0 text-foreground/40 transition-colors group-hover:text-foreground/70" strokeWidth={1.5} />
|
||||
</Link>
|
||||
<button type="button" className="app-workspace-sidebar-collapse-button" aria-label="收起侧栏" title="收起侧栏" onClick={onCollapse}>
|
||||
<PanelLeftClose className="size-4" strokeWidth={1.7} />
|
||||
</button>
|
||||
|
||||
{isOpen ? (
|
||||
<>
|
||||
<div className="fixed inset-0 z-40" onClick={() => setIsOpen(false)} />
|
||||
<div className="app-workspace-nav-popover absolute left-3 right-3 top-full z-50 mt-1 overflow-hidden rounded-lg border border-[var(--workspace-border)] bg-[var(--workspace-surface-strong)] py-1 animate-in fade-in zoom-in-95 duration-100">
|
||||
<div className="px-3 py-2.5">
|
||||
<div className="truncate text-[var(--fs-body)] font-semibold">{appearance.brandName}</div>
|
||||
<div className="mt-0.5 truncate text-[var(--fs-label)] text-foreground/45">创作工作台</div>
|
||||
</div>
|
||||
<div className="mx-2 my-1 h-px bg-[var(--workspace-border)]" />
|
||||
<a href="/welcome" className="flex w-full items-center gap-2 px-3 py-2 text-[var(--fs-body)] text-foreground/80 transition-colors hover:bg-surface-hover hover:text-foreground">
|
||||
品牌首页
|
||||
</a>
|
||||
{[
|
||||
{ label: "首页", to: "/" },
|
||||
{ label: "画布", to: "/canvas" },
|
||||
{ label: "设置", to: "/settings" },
|
||||
].map((entry) => (
|
||||
<button key={entry.to} type="button" onClick={() => go(entry.to)} className="flex w-full items-center gap-2 px-3 py-2 text-[var(--fs-body)] text-foreground/80 transition-colors hover:bg-surface-hover hover:text-foreground">
|
||||
{entry.label}
|
||||
</button>
|
||||
))}
|
||||
<div className="mx-2 my-1 h-px bg-[var(--workspace-border)]" />
|
||||
<button type="button" onClick={() => go("/canvas?mode=new")} className="flex w-full items-center gap-2 px-3 py-2 text-[var(--fs-body)] text-foreground/45 transition-colors hover:bg-surface-hover hover:text-foreground">
|
||||
<Plus className="size-3.5" /> 新建画布
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -177,15 +148,18 @@ function NavItem({
|
||||
const Icon = item.icon;
|
||||
const rowStyle = collapsed ? undefined : ({ paddingLeft: `${level * 12 + 10}px` } as CSSProperties);
|
||||
|
||||
const collapsedTitle = item.id === "home" ? "创作" : item.id === "projects" ? "短剧" : item.id === "canvas" ? "画布" : item.id === "assets" ? "资产" : item.id === "skills" ? "技能" : item.id === "plugins" ? "插件" : item.id === "tasks" ? "历史" : item.title.slice(0, 2);
|
||||
const rowContent = (
|
||||
<>
|
||||
<span className="app-workspace-nav-main flex min-w-0 items-center gap-2.5">
|
||||
{Icon ? (
|
||||
<Icon className={cn("size-4 shrink-0", isActive ? "text-foreground" : "text-foreground/60 group-hover:text-foreground/80")} strokeWidth={1.6} />
|
||||
<span className="app-workspace-nav-icon" aria-hidden>
|
||||
<Icon className="size-4 shrink-0" strokeWidth={1.8} />
|
||||
</span>
|
||||
) : (
|
||||
<span className="size-1.5 shrink-0 rounded-full bg-current opacity-40" aria-hidden />
|
||||
)}
|
||||
<span className="app-workspace-nav-title truncate">{item.title}</span>
|
||||
<span className="app-workspace-nav-title truncate">{collapsed ? collapsedTitle : item.title}</span>
|
||||
</span>
|
||||
<span className="app-workspace-nav-meta flex shrink-0 items-center gap-2">
|
||||
{item.shortcut ? (
|
||||
@@ -198,7 +172,7 @@ function NavItem({
|
||||
);
|
||||
|
||||
const rowClassName = cn(
|
||||
"app-workspace-nav-link group flex min-h-9 w-full items-center justify-between gap-2 rounded-[var(--r-sm)] px-2.5 py-2 text-[var(--fs-body)] transition-colors duration-200 select-none",
|
||||
"app-workspace-nav-link group flex min-h-11 w-full items-center justify-between gap-2 rounded-[var(--r-md)] px-3 py-2 text-[var(--fs-body)] transition-[background-color,color,transform] duration-200 select-none",
|
||||
collapsed && "is-collapsed",
|
||||
isActive ? "is-active font-medium" : "text-foreground/62 hover:bg-surface-hover hover:text-foreground",
|
||||
);
|
||||
@@ -228,6 +202,7 @@ function NavItem({
|
||||
<Link
|
||||
to={linkTo}
|
||||
className={rowClassName}
|
||||
data-nav-id={item.id}
|
||||
style={rowStyle}
|
||||
aria-label={collapsed ? item.title : undefined}
|
||||
title={collapsed ? item.title : undefined}
|
||||
@@ -239,7 +214,7 @@ function NavItem({
|
||||
{rowContent}
|
||||
</Link>
|
||||
) : (
|
||||
<button type="button" className={rowClassName} style={rowStyle} aria-label={collapsed ? item.title : undefined} title={collapsed ? item.title : undefined} onClick={handleClick} aria-expanded={hasChildren ? isOpen : undefined}>
|
||||
<button type="button" className={rowClassName} data-nav-id={item.id} style={rowStyle} aria-label={collapsed ? item.title : undefined} title={collapsed ? item.title : undefined} onClick={handleClick} aria-expanded={hasChildren ? isOpen : undefined}>
|
||||
{rowContent}
|
||||
</button>
|
||||
)}
|
||||
@@ -268,7 +243,7 @@ function NavGroup({ group, activeId, onNavigate, onOpenSearch, onLogout, collaps
|
||||
}, [hasActive]);
|
||||
|
||||
const content = (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<div className="flex flex-col gap-2">
|
||||
{group.items.map((item) => (
|
||||
<NavItem key={item.id} item={item} activeId={activeId} onSelect={onNavigate} onOpenSearch={onOpenSearch} onLogout={onLogout} collapsed={collapsed} />
|
||||
))}
|
||||
@@ -277,11 +252,11 @@ function NavGroup({ group, activeId, onNavigate, onOpenSearch, onLogout, collaps
|
||||
|
||||
// 无标题分组(核心导航入口)常驻展示,不做折叠。
|
||||
if (!group.heading || collapsed) {
|
||||
return <div className="flex shrink-0 flex-col">{content}</div>;
|
||||
return <div className="app-workspace-nav-group flex shrink-0 flex-col" data-nav-group-heading={group.heading || ""}>{content}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex shrink-0 flex-col">
|
||||
<div className="app-workspace-nav-group flex shrink-0 flex-col" data-nav-group-heading={group.heading}>
|
||||
<button type="button" onClick={() => setIsOpen((open) => !open)} aria-expanded={isOpen} className="app-workspace-nav-group-toggle select-none">
|
||||
<span className="app-workspace-nav-group-label">{group.heading}</span>
|
||||
<ChevronRight className={cn("size-3.5 shrink-0 text-foreground/35 transition-transform duration-200", isOpen && "rotate-90")} strokeWidth={2} />
|
||||
@@ -293,18 +268,13 @@ function NavGroup({ group, activeId, onNavigate, onOpenSearch, onLogout, collaps
|
||||
);
|
||||
}
|
||||
|
||||
export function WorkspaceSidebarNav({ collapsed, onNavigate, onOpenSearch, onExpand }: { collapsed: boolean; onNavigate: () => void; onOpenSearch: () => void; onExpand: () => void }) {
|
||||
export function WorkspaceSidebarNav({ collapsed, onNavigate, onOpenSearch, onExpand, onCollapse }: { collapsed: boolean; onNavigate: () => void; onOpenSearch: () => void; onExpand: () => void; onCollapse: () => void }) {
|
||||
const { pathname } = useLocation();
|
||||
const [searchParams] = useSearchParams();
|
||||
const features = useUserStore((state) => state.features);
|
||||
const user = useUserStore((state) => state.user);
|
||||
const creditsEnabled = useUserStore((state) => state.features.creditsEnabled);
|
||||
const { availableMicrocredits } = useWalletBalance(user?.id, creditsEnabled);
|
||||
const { handleLogout } = useWorkspaceLogout();
|
||||
|
||||
const balance = availableMicrocredits === null ? "--" : (availableMicrocredits / 1_000_000).toLocaleString("zh-CN", { maximumFractionDigits: 2 });
|
||||
|
||||
const { groups, footer } = useMemo(() => buildNav(features, balance, user?.role === "admin"), [features, balance, user?.role]);
|
||||
const { groups, footer } = useMemo(() => buildNav(features, user?.role === "admin"), [features, user?.role]);
|
||||
|
||||
const slug = pathname.split("/").filter(Boolean)[0] || "home";
|
||||
const section = searchParams.get("section");
|
||||
@@ -327,26 +297,12 @@ export function WorkspaceSidebarNav({ collapsed, onNavigate, onOpenSearch, onExp
|
||||
|
||||
return (
|
||||
<div className={cn("app-workspace-sidebar-nav flex h-full shrink-0 flex-col", collapsed && "is-collapsed")}>
|
||||
<WorkspaceSwitcher collapsed={collapsed} onNavigate={onNavigate} onExpand={onExpand} />
|
||||
|
||||
<div className="app-workspace-sidebar-search shrink-0 px-3 pb-1 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenSearch}
|
||||
className="group flex h-9 w-full items-center gap-2 rounded-[var(--r-lg)] bg-foreground/5 px-3 text-left text-[var(--fs-caption)] text-muted-foreground transition-colors hover:bg-foreground/[.07] hover:text-foreground/70"
|
||||
aria-label={collapsed ? "快速搜索" : undefined}
|
||||
title={collapsed ? "快速搜索" : undefined}
|
||||
>
|
||||
<Search className="size-4 shrink-0 text-muted-foreground group-hover:text-foreground/70" strokeWidth={1.6} />
|
||||
<span className="app-workspace-sidebar-search-label flex-1 truncate">快速搜索</span>
|
||||
<Kbd className="app-workspace-sidebar-search-shortcut">⌘K</Kbd>
|
||||
</button>
|
||||
</div>
|
||||
<WorkspaceSwitcher collapsed={collapsed} onNavigate={onNavigate} onExpand={onExpand} onCollapse={onCollapse} />
|
||||
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onScroll={handleScroll}
|
||||
className={cn("app-workspace-sidebar-scroll-area flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-3 pb-3 pt-2", collapsed && "is-collapsed", scrollState.hasTopFade && "has-top-fade", scrollState.hasBottomFade && "has-bottom-fade")}
|
||||
className={cn("app-workspace-sidebar-scroll-area flex min-h-0 flex-1 flex-col gap-5 overflow-y-auto px-3 pb-3 pt-7", collapsed && "is-collapsed", scrollState.hasTopFade && "has-top-fade", scrollState.hasBottomFade && "has-bottom-fade")}
|
||||
>
|
||||
{groups.map((group, index) => (
|
||||
<NavGroup key={index} group={group} activeId={activeId} onNavigate={onNavigate} onOpenSearch={onOpenSearch} onLogout={() => void handleLogout()} collapsed={collapsed} />
|
||||
@@ -354,11 +310,12 @@ export function WorkspaceSidebarNav({ collapsed, onNavigate, onOpenSearch, onExp
|
||||
</div>
|
||||
|
||||
<div className="app-workspace-sidebar-footer shrink-0 px-3 py-3">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<WorkspaceSidebarProfile collapsed={collapsed} user={user} />
|
||||
{footer.length ? <div className="mt-2 flex flex-col gap-0.5">
|
||||
{footer.map((item) => (
|
||||
<NavItem key={item.id} item={item} activeId={activeId} onSelect={onNavigate} onOpenSearch={onOpenSearch} onLogout={() => void handleLogout()} collapsed={collapsed} />
|
||||
))}
|
||||
</div>
|
||||
</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { PanelLeftClose, PanelLeftOpen } from "lucide-react";
|
||||
import { Coins, PanelLeftClose, PanelLeftOpen } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Link, useLocation } from "react-router";
|
||||
|
||||
import { SystemAnnouncementCenter } from "@/components/layout/system-announcement-center";
|
||||
@@ -8,49 +9,59 @@ import { AnimatedThemeToggler } from "@/components/ui/animated-theme-toggler";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
import { useUserStore } from "@/stores/use-user-store";
|
||||
import { useAppearanceStore } from "@/stores/use-appearance-store";
|
||||
import { useWalletBalance } from "@/hooks/use-wallet-balance";
|
||||
import { WorkspaceWalletModal } from "@/components/layout/workspace-wallet-modal";
|
||||
|
||||
const PAGE_TITLES: Record<string, string> = {
|
||||
home: "首页",
|
||||
home: "创作",
|
||||
create: "创作",
|
||||
projects: "短剧创作",
|
||||
canvas: "画布",
|
||||
tasks: "任务",
|
||||
assets: "素材",
|
||||
skills: "技能库",
|
||||
wallet: "积分中心",
|
||||
projects: "短剧 Agent",
|
||||
canvas: "自由画布",
|
||||
tasks: "创作历史",
|
||||
assets: "资产",
|
||||
skills: "技能",
|
||||
plugins: "插件",
|
||||
wallet: "积分超市",
|
||||
settings: "设置",
|
||||
};
|
||||
|
||||
export function WorkspaceTopBar({ sidebarOpen, onToggleSidebar }: { sidebarOpen: boolean; onToggleSidebar: () => void }) {
|
||||
const [walletOpen, setWalletOpen] = useState(false);
|
||||
const brandName = useAppearanceStore((state) => state.appearance.brandName);
|
||||
const theme = useThemeStore((state) => state.theme);
|
||||
const setTheme = useThemeStore((state) => state.setTheme);
|
||||
const user = useUserStore((state) => state.user);
|
||||
const creditsEnabled = useUserStore((state) => state.features.creditsEnabled);
|
||||
const { availableMicrocredits } = useWalletBalance(user?.id, creditsEnabled);
|
||||
const { pathname } = useLocation();
|
||||
const extension = useWorkspaceTopBarContent();
|
||||
|
||||
const slug = pathname.split("/").filter(Boolean)[0];
|
||||
const pageTitle = (slug && PAGE_TITLES[slug]) || brandName;
|
||||
const pageTitle = slug ? PAGE_TITLES[slug] || brandName : PAGE_TITLES.home;
|
||||
const balance = availableMicrocredits === null ? "--" : (availableMicrocredits / 1_000_000).toLocaleString("zh-CN", { maximumFractionDigits: 2 });
|
||||
|
||||
return (
|
||||
<header className={`app-workspace-topbar flex shrink-0 items-center justify-between gap-2 px-3 sm:px-4 ${extension ? "has-extension" : ""}`}>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<button type="button" className="app-workspace-topbar-icon-button" aria-label={sidebarOpen ? "收起侧栏" : "展开侧栏"} onClick={onToggleSidebar}>
|
||||
{sidebarOpen ? <PanelLeftClose className="size-4" /> : <PanelLeftOpen className="size-4" />}
|
||||
</button>
|
||||
<nav className={`${extension ? "hidden 2xl:flex" : "flex"} min-w-0 items-center gap-2 text-[var(--fs-caption)] text-foreground/50`} aria-label="当前位置">
|
||||
<Link to="/" className="shrink-0 font-medium text-foreground/70 transition-colors hover:text-foreground">{brandName}</Link>
|
||||
<span className="shrink-0 text-foreground/30">/</span>
|
||||
<span className="truncate font-medium text-foreground">{pageTitle}</span>
|
||||
</nav>
|
||||
{extension ? <div className="min-w-0 flex-1">{extension}</div> : null}
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<header className={`app-workspace-topbar ${extension ? "has-extension" : ""}`}>
|
||||
<button type="button" className="app-workspace-mobile-menu app-workspace-topbar-icon-button" aria-label={sidebarOpen ? "收起侧栏" : "展开侧栏"} onClick={onToggleSidebar}>
|
||||
{sidebarOpen ? <PanelLeftClose className="size-4" /> : <PanelLeftOpen className="size-4" />}
|
||||
</button>
|
||||
<nav className="app-workspace-topbar-breadcrumb" aria-label="当前位置">
|
||||
<Link to="/" className="font-medium text-foreground/65 transition-colors hover:text-foreground">{brandName}</Link>
|
||||
<span aria-hidden="true">/</span>
|
||||
<span className="truncate font-medium text-foreground">{pageTitle}</span>
|
||||
</nav>
|
||||
{extension ? <div className="app-workspace-topbar-extension">{extension}</div> : null}
|
||||
<div className="app-workspace-topbar-actions">
|
||||
{creditsEnabled ? <button type="button" className="app-workspace-topbar-credit-pill" aria-label={`打开积分中心,可用 ${balance} 积分`} onClick={() => setWalletOpen(true)}>
|
||||
<Coins aria-hidden="true" />
|
||||
<span>积分</span>
|
||||
<strong>{balance}</strong>
|
||||
</button> : null}
|
||||
{user ? <SystemAnnouncementCenter userId={user.id} className="app-workspace-topbar-icon-button" autoOpen /> : null}
|
||||
<AnimatedThemeToggler className="app-workspace-topbar-icon-button" theme={theme} onThemeChange={setTheme} aria-label="切换主题" />
|
||||
<WorkspaceAccountMenu />
|
||||
</div>
|
||||
{creditsEnabled ? <WorkspaceWalletModal open={walletOpen} onClose={() => setWalletOpen(false)} /> : null}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
289
web/src/components/layout/workspace-wallet-modal.tsx
Normal file
289
web/src/components/layout/workspace-wallet-modal.tsx
Normal file
@@ -0,0 +1,289 @@
|
||||
import { App, Button, Input, QRCode, Skeleton } from "antd";
|
||||
import { Check, ChevronLeft, ChevronRight, CircleAlert, Coins, CreditCard, History, RefreshCw, TicketCheck, WalletCards } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { AppModal } from "@/components/ui/product/app-modal";
|
||||
import { formatCredits } from "@/constant/credits";
|
||||
import { closePaymentOrder, createPaymentOrder, listPaymentProviders, listTopupProducts, queryPaymentOrder, refreshPaymentCheckout, type PaymentOrder, type PaymentProvider, type TopupProduct } from "@/services/api/payments";
|
||||
import { getWallet, redeemCredits, type CreditLedgerEntry, type WalletSummary } from "@/services/api/wallet";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type WalletModalTab = "topup" | "history";
|
||||
|
||||
export function WorkspaceWalletModal({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
const { message } = App.useApp();
|
||||
const [tab, setTab] = useState<WalletModalTab>("topup");
|
||||
const [wallet, setWallet] = useState<WalletSummary | null>(null);
|
||||
const [walletLoading, setWalletLoading] = useState(false);
|
||||
const [walletError, setWalletError] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
const [products, setProducts] = useState<TopupProduct[]>([]);
|
||||
const [providers, setProviders] = useState<PaymentProvider[]>([]);
|
||||
const [paymentsLoading, setPaymentsLoading] = useState(false);
|
||||
const [selectedProductId, setSelectedProductId] = useState("");
|
||||
const [selectedProviderId, setSelectedProviderId] = useState("");
|
||||
const [code, setCode] = useState("");
|
||||
const [redeeming, setRedeeming] = useState(false);
|
||||
const [paymentCreating, setPaymentCreating] = useState(false);
|
||||
const [paymentQuerying, setPaymentQuerying] = useState(false);
|
||||
const [paymentOrder, setPaymentOrder] = useState<PaymentOrder | null>(null);
|
||||
const [paymentOpen, setPaymentOpen] = useState(false);
|
||||
const [clock, setClock] = useState(Date.now());
|
||||
const idempotencyKey = useRef("");
|
||||
const completedOrderId = useRef("");
|
||||
const requestSequence = useRef(0);
|
||||
|
||||
const selectedProduct = useMemo(() => products.find((item) => item.id === selectedProductId), [products, selectedProductId]);
|
||||
const selectedProvider = useMemo(() => providers.find((item) => item.id === selectedProviderId), [providers, selectedProviderId]);
|
||||
|
||||
const reloadWallet = async (targetPage = page) => {
|
||||
const sequence = ++requestSequence.current;
|
||||
setWalletLoading(true);
|
||||
setWalletError("");
|
||||
try {
|
||||
const result = await getWallet(targetPage, 20, "all");
|
||||
if (sequence === requestSequence.current) setWallet(result);
|
||||
} catch (error) {
|
||||
if (sequence === requestSequence.current) setWalletError(error instanceof Error ? error.message : "读取积分账户失败");
|
||||
} finally {
|
||||
if (sequence === requestSequence.current) setWalletLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setPage(1);
|
||||
void reloadWallet(1);
|
||||
setPaymentsLoading(true);
|
||||
Promise.all([listTopupProducts(), listPaymentProviders()])
|
||||
.then(([productResult, providerResult]) => {
|
||||
setProducts(productResult.products.filter((item) => item.enabled));
|
||||
setProviders(providerResult.providers.filter((item) => item.enabled && item.pluginEnabled && item.configured));
|
||||
setSelectedProductId((current) => current || productResult.products.find((item) => item.enabled)?.id || "");
|
||||
setSelectedProviderId((current) => current || providerResult.providers.find((item) => item.enabled && item.pluginEnabled && item.configured)?.id || "");
|
||||
})
|
||||
.catch((error) => message.error(error instanceof Error ? error.message : "读取充值配置失败"))
|
||||
.finally(() => setPaymentsLoading(false));
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
idempotencyKey.current = "";
|
||||
}, [selectedProductId, selectedProviderId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!paymentOpen || !paymentOrder || !["created", "pending", "closing"].includes(paymentOrder.status)) return;
|
||||
const interval = window.setInterval(() => {
|
||||
void refreshPaymentStatus(paymentOrder.id, true);
|
||||
}, 4_000);
|
||||
return () => window.clearInterval(interval);
|
||||
}, [paymentOpen, paymentOrder?.id, paymentOrder?.status]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!paymentOpen) return;
|
||||
const interval = window.setInterval(() => setClock(Date.now()), 1_000);
|
||||
return () => window.clearInterval(interval);
|
||||
}, [paymentOpen]);
|
||||
|
||||
const announceWalletUpdated = async (orderId?: string) => {
|
||||
if (orderId && completedOrderId.current === orderId) return;
|
||||
if (orderId) completedOrderId.current = orderId;
|
||||
setPage(1);
|
||||
await reloadWallet(1);
|
||||
window.dispatchEvent(new CustomEvent("wallet:updated"));
|
||||
};
|
||||
|
||||
const redeem = async () => {
|
||||
const normalized = code.trim().toLowerCase();
|
||||
if (normalized.length !== 32) {
|
||||
message.error("请输入完整的 32 位兑换码");
|
||||
return;
|
||||
}
|
||||
setRedeeming(true);
|
||||
try {
|
||||
await redeemCredits(normalized);
|
||||
setCode("");
|
||||
await announceWalletUpdated();
|
||||
message.success("兑换成功,积分已到账");
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "兑换失败");
|
||||
} finally {
|
||||
setRedeeming(false);
|
||||
}
|
||||
};
|
||||
|
||||
const startPayment = async () => {
|
||||
if (!selectedProduct || !selectedProvider) {
|
||||
message.error("请选择充值商品和支付方式");
|
||||
return;
|
||||
}
|
||||
setPaymentCreating(true);
|
||||
try {
|
||||
if (!idempotencyKey.current) idempotencyKey.current = crypto.randomUUID();
|
||||
const result = await createPaymentOrder({ productId: selectedProduct.id, providerId: selectedProvider.id, idempotencyKey: idempotencyKey.current });
|
||||
idempotencyKey.current = "";
|
||||
setPaymentOrder(result.order);
|
||||
if (result.order.status === "credited") await announceWalletUpdated(result.order.id);
|
||||
if (result.order.checkout.mode === "redirect" && result.order.checkout.url) {
|
||||
window.location.assign(result.order.checkout.url);
|
||||
return;
|
||||
}
|
||||
setPaymentOpen(true);
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "创建支付订单失败");
|
||||
} finally {
|
||||
setPaymentCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
async function refreshPaymentStatus(orderId = paymentOrder?.id, silent = false) {
|
||||
if (!orderId || paymentQuerying) return;
|
||||
setPaymentQuerying(true);
|
||||
try {
|
||||
const result = await queryPaymentOrder(orderId);
|
||||
setPaymentOrder(result.order);
|
||||
if (result.order.status === "credited") {
|
||||
await announceWalletUpdated(result.order.id);
|
||||
if (!silent) message.success("支付已确认,积分已到账");
|
||||
} else if (!silent && result.order.status === "closed") message.warning("订单已关闭,未产生积分充值");
|
||||
else if (!silent) message.info("渠道尚未确认支付,请稍后再试");
|
||||
} catch (error) {
|
||||
if (!silent) message.error(error instanceof Error ? error.message : "查询支付结果失败");
|
||||
} finally {
|
||||
setPaymentQuerying(false);
|
||||
}
|
||||
}
|
||||
|
||||
const cancelPayment = async () => {
|
||||
if (!paymentOrder) return;
|
||||
setPaymentQuerying(true);
|
||||
try {
|
||||
const result = await closePaymentOrder(paymentOrder.id);
|
||||
setPaymentOrder(result.order);
|
||||
if (result.order.status === "credited") await announceWalletUpdated(result.order.id);
|
||||
else message.success("未支付订单已关闭");
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "关闭订单失败");
|
||||
} finally {
|
||||
setPaymentQuerying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const retryCheckout = async () => {
|
||||
if (!paymentOrder) return;
|
||||
setPaymentQuerying(true);
|
||||
try {
|
||||
const result = await refreshPaymentCheckout(paymentOrder.id);
|
||||
setPaymentOrder(result.order);
|
||||
if (result.order.checkout.mode === "redirect" && result.order.checkout.url) window.location.assign(result.order.checkout.url);
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "刷新支付入口失败");
|
||||
} finally {
|
||||
setPaymentQuerying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const available = wallet?.account.availableMicrocredits ?? 0;
|
||||
const totalPages = Math.max(1, Math.ceil((wallet?.total || 0) / 20));
|
||||
|
||||
return (
|
||||
<>
|
||||
<AppModal flush open={open} title={null} footer={null} centered width="min(880px, calc(100vw - 28px))" onCancel={onClose} rootClassName="workspace-wallet-modal">
|
||||
<div className="workspace-wallet-shell">
|
||||
<header className="workspace-wallet-header">
|
||||
<div>
|
||||
<span className="workspace-wallet-kicker"><Coins />积分中心</span>
|
||||
<h2>充值、兑换与消费记录</h2>
|
||||
<p>为下一次创作补充积分,随时查看每一笔收支。</p>
|
||||
</div>
|
||||
<div className="workspace-wallet-balance">
|
||||
<span>可用积分</span>
|
||||
<strong>{wallet ? formatCredits(available, 6) : "--"}</strong>
|
||||
<small>冻结 {wallet ? formatCredits(wallet.account.reservedMicrocredits, 6) : "--"}</small>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="workspace-wallet-tabs" role="tablist" aria-label="积分中心">
|
||||
<button type="button" role="tab" aria-selected={tab === "topup"} onClick={() => setTab("topup")}><WalletCards />充值 / 兑换</button>
|
||||
<button type="button" role="tab" aria-selected={tab === "history"} onClick={() => setTab("history")}><History />积分消耗历史</button>
|
||||
</div>
|
||||
|
||||
{tab === "topup" ? (
|
||||
<div className="workspace-wallet-content is-topup">
|
||||
<section className="workspace-wallet-section">
|
||||
<div className="workspace-wallet-section-heading"><div><h3>在线充值</h3><p>选择积分套餐和支付方式。</p></div><CreditCard /></div>
|
||||
{paymentsLoading ? <Skeleton active paragraph={{ rows: 4 }} /> : products.length && providers.length ? <>
|
||||
<div className="workspace-wallet-products">
|
||||
{products.map((product) => <button key={product.id} type="button" className={cn("workspace-wallet-product", selectedProductId === product.id && "is-selected")} aria-pressed={selectedProductId === product.id} onClick={() => setSelectedProductId(product.id)}>
|
||||
<span>{product.name}</span><strong>{formatCredits(product.creditsMicrocredits, 6)} 积分</strong><small>¥ {(product.amountFen / 100).toFixed(2)}{product.description ? ` · ${product.description}` : ""}</small>{selectedProductId === product.id ? <Check /> : null}
|
||||
</button>)}
|
||||
</div>
|
||||
<div className="workspace-wallet-provider-row">
|
||||
<div className="workspace-wallet-providers" role="radiogroup" aria-label="支付方式">
|
||||
{providers.map((provider) => <button key={provider.id} type="button" role="radio" aria-checked={selectedProviderId === provider.id} className={selectedProviderId === provider.id ? "is-selected" : ""} onClick={() => setSelectedProviderId(provider.id)}><CreditCard />{provider.name}</button>)}
|
||||
</div>
|
||||
<Button type="primary" size="large" loading={paymentCreating} disabled={!selectedProduct || !selectedProvider} onClick={() => void startPayment()}>立即充值</Button>
|
||||
</div>
|
||||
</> : <div className="workspace-wallet-inline-state"><CircleAlert /><div><strong>在线充值暂不可用</strong><span>当前没有已启用的充值商品或支付渠道,请使用兑换码或联系管理员。</span></div></div>}
|
||||
</section>
|
||||
|
||||
<section className="workspace-wallet-section is-redeem">
|
||||
<div className="workspace-wallet-section-heading"><div><h3>兑换码</h3><p>输入兑换码,将积分存入当前账户。</p></div><TicketCheck /></div>
|
||||
<div className="workspace-wallet-redeem-row">
|
||||
<Input size="large" value={code} maxLength={32} placeholder="输入 32 位兑换码" onChange={(event) => setCode(event.target.value.replace(/\s/g, ""))} onPressEnter={() => void redeem()} />
|
||||
<Button size="large" loading={redeeming} disabled={code.trim().length !== 32} onClick={() => void redeem()}>确认兑换</Button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
) : (
|
||||
<div className="workspace-wallet-content is-history">
|
||||
<div className="workspace-wallet-history-toolbar"><div><h3>积分消耗历史</h3><p>包含充值、兑换、生成消费、冻结与退款。</p></div><Button type="text" icon={<RefreshCw />} loading={walletLoading} onClick={() => void reloadWallet(page)}>刷新</Button></div>
|
||||
<div className="workspace-wallet-history-scroll">
|
||||
{walletError ? <div className="workspace-wallet-inline-state is-error"><CircleAlert /><div><strong>记录加载失败</strong><span>{walletError}</span></div><Button onClick={() => void reloadWallet(page)}>重试</Button></div> : walletLoading && !wallet ? <Skeleton active paragraph={{ rows: 6 }} /> : wallet?.entries.length ? <div className="workspace-wallet-ledger">
|
||||
{wallet.entries.map((entry) => <WalletLedgerRow key={entry.id} entry={entry} />)}
|
||||
</div> : <div className="workspace-wallet-empty"><History /><strong>还没有积分记录</strong><span>完成充值、兑换或生成任务后,记录会显示在这里。</span></div>}
|
||||
</div>
|
||||
<div className="workspace-wallet-pagination">
|
||||
<span>第 {page} / {totalPages} 页</span>
|
||||
<button type="button" disabled={page <= 1 || walletLoading} aria-label="上一页" onClick={() => { const next = page - 1; setPage(next); void reloadWallet(next); }}><ChevronLeft /></button>
|
||||
<button type="button" disabled={page >= totalPages || walletLoading} aria-label="下一页" onClick={() => { const next = page + 1; setPage(next); void reloadWallet(next); }}><ChevronRight /></button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AppModal>
|
||||
|
||||
<AppModal open={paymentOpen} title={paymentOrder?.status === "credited" ? "充值完成" : paymentOrder?.checkout.mode === "qr_code" ? "扫码支付" : "确认支付结果"} centered width={430} onCancel={() => setPaymentOpen(false)} footer={paymentFooter(paymentOrder, paymentQuerying, () => setPaymentOpen(false), cancelPayment, refreshPaymentStatus, retryCheckout)}>
|
||||
{paymentOrder ? <div className="workspace-wallet-payment">
|
||||
<span className="workspace-wallet-payment-icon"><CreditCard /></span>
|
||||
<strong>¥ {(paymentOrder.amountFen / 100).toFixed(2)}</strong>
|
||||
<p>{paymentOrder.productName} · {formatCredits(paymentOrder.creditsMicrocredits, 6)} 积分</p>
|
||||
{paymentOrder.status === "pending" && paymentOrder.checkout.mode === "qr_code" && paymentOrder.checkout.value ? <><QRCode value={paymentOrder.checkout.value} size={208} bordered={false} /><span>请使用支付应用扫码完成支付</span></> : null}
|
||||
<PaymentStatus order={paymentOrder} now={clock} />
|
||||
</div> : null}
|
||||
</AppModal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function WalletLedgerRow({ entry }: { entry: CreditLedgerEntry }) {
|
||||
const positive = entry.amountMicrocredits > 0;
|
||||
const title = entry.type === "consume" ? "模型调用" : entry.type === "refund" ? "消费退款" : entry.type === "payment_topup" ? "在线充值" : entry.type === "redeem" ? "兑换码充值" : entry.note || "积分调整";
|
||||
return <article className="workspace-wallet-ledger-row"><span className={cn("workspace-wallet-ledger-icon", positive ? "is-income" : "is-consume")}>{positive ? <Coins /> : <CreditCard />}</span><div><strong>{title}</strong><span>{[entry.scene, entry.model, entry.note].filter(Boolean).join(" · ") || "积分账户变动"}</span></div><time>{new Date(entry.createdAt).toLocaleString("zh-CN", { hour12: false })}</time><b className={positive ? "is-income" : "is-consume"}>{positive ? "+" : ""}{formatCredits(entry.amountMicrocredits, 6)}</b></article>;
|
||||
}
|
||||
|
||||
function PaymentStatus({ order, now }: { order: PaymentOrder; now: number }) {
|
||||
if (order.status === "credited") return <div className="workspace-wallet-payment-status is-success">支付已确认,积分已经到账</div>;
|
||||
if (order.status === "closed") return <div className="workspace-wallet-payment-status">订单已关闭,未产生积分充值</div>;
|
||||
if (order.status === "create_failed") return <div className="workspace-wallet-payment-status is-error">支付入口创建失败,请重新生成支付入口。</div>;
|
||||
const remaining = Math.max(0, Math.floor((new Date(order.expiresAt).getTime() - now) / 1000));
|
||||
const hours = Math.floor(remaining / 3600);
|
||||
const minutes = Math.floor((remaining % 3600) / 60);
|
||||
const seconds = remaining % 60;
|
||||
return <div className="workspace-wallet-payment-status">订单剩余 {String(hours).padStart(2, "0")}:{String(minutes).padStart(2, "0")}:{String(seconds).padStart(2, "0")},将自动确认支付结果</div>;
|
||||
}
|
||||
|
||||
function paymentFooter(order: PaymentOrder | null, loading: boolean, close: () => void, cancel: () => Promise<void>, query: (id?: string, silent?: boolean) => Promise<void>, retry: () => Promise<void>) {
|
||||
if (order?.status === "pending") return [<Button key="cancel" danger disabled={loading} onClick={() => void cancel()}>关闭订单</Button>, <Button key="query" type="primary" loading={loading} onClick={() => void query()}>我已完成支付</Button>];
|
||||
if (order?.status === "create_failed") return [<Button key="close" onClick={close}>稍后处理</Button>, <Button key="retry" type="primary" loading={loading} onClick={() => void retry()}>重新生成支付入口</Button>];
|
||||
return [<Button key="done" type="primary" onClick={close}>完成</Button>];
|
||||
}
|
||||
37
web/src/components/ui/base/buttons/delete-button.tsx
Normal file
37
web/src/components/ui/base/buttons/delete-button.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { Button, Popover } from "antd";
|
||||
import { Check, Trash2, X } from "lucide-react";
|
||||
import { useRef, useState } from "react";
|
||||
|
||||
/** 原位确认只在真实请求完成后关闭;失败保留操作上下文。 */
|
||||
export function DeleteButton({ label, description, onConfirm }: { label: string; description: string; onConfirm: () => Promise<unknown> }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [pending, setPending] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const busy = useRef(false);
|
||||
const trigger = useRef<HTMLButtonElement>(null);
|
||||
const close = () => { if (!busy.current) { setOpen(false); trigger.current?.focus(); } };
|
||||
const confirm = async () => {
|
||||
if (busy.current) return;
|
||||
busy.current = true;
|
||||
setPending(true);
|
||||
setError("");
|
||||
try { await onConfirm(); setOpen(false); }
|
||||
catch (cause) { setError(cause instanceof Error ? cause.message : "删除失败,请重试"); }
|
||||
finally { busy.current = false; setPending(false); }
|
||||
};
|
||||
return <span className="product-delete" onClick={(event) => { event.preventDefault(); event.stopPropagation(); }} onKeyDown={(event) => { if (event.key === "Escape") { event.stopPropagation(); close(); } }}>
|
||||
<Popover trigger="click" placement="bottomRight" open={open} onOpenChange={(next) => { if (!busy.current) { setOpen(next); setError(""); } }} content={
|
||||
<div className="product-delete-confirm" role="dialog" aria-label={label} aria-busy={pending}>
|
||||
<strong>{label}?</strong>
|
||||
<p>{description}</p>
|
||||
{error ? <p role="alert" className="product-delete-error">{error}</p> : null}
|
||||
<div className="product-delete-confirm-actions">
|
||||
<Button autoFocus icon={<X />} disabled={pending} onClick={close}>取消</Button>
|
||||
<Button danger type="primary" icon={<Check />} loading={pending} onClick={() => void confirm()}>确认删除</Button>
|
||||
</div>
|
||||
</div>
|
||||
}>
|
||||
<button ref={trigger} type="button" className="product-icon-button product-delete-trigger" aria-label={label} aria-expanded={open} aria-haspopup="dialog"><Trash2 /></button>
|
||||
</Popover>
|
||||
</span>;
|
||||
}
|
||||
9
web/src/components/ui/product/media-placeholder.tsx
Normal file
9
web/src/components/ui/product/media-placeholder.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import { ImageOff, Layers3 } from "lucide-react";
|
||||
|
||||
export function MediaPlaceholder({ failed = false, label }: { failed?: boolean; label?: string }) {
|
||||
const Icon = failed ? ImageOff : Layers3;
|
||||
return <span className="product-media-placeholder" role={failed ? "img" : undefined} aria-label={failed ? "封面加载失败" : undefined}>
|
||||
<span className="product-media-placeholder-icon"><Icon aria-hidden="true" /></span>
|
||||
<span>{label || (failed ? "封面暂不可用" : "从一个想法开始")}</span>
|
||||
</span>;
|
||||
}
|
||||
@@ -20,7 +20,7 @@ export interface SettingsRowProps {
|
||||
*/
|
||||
export function SettingsRow({ label, description, control, align = "center", className, controlClassName }: SettingsRowProps) {
|
||||
return (
|
||||
<div className={cn("flex w-full items-center gap-4 border-b border-foreground/10 py-2.5 last:border-b-0", align === "top" && "items-start", className)}>
|
||||
<div className={cn("flex w-full items-center gap-4 border-b border-foreground/10 py-3.5 last:border-b-0 last:pb-1", align === "top" && "items-start", className)}>
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="block text-sm font-medium text-foreground">{label}</span>
|
||||
{description ? <span className="mt-0.5 block text-xs leading-4 text-foreground/55">{description}</span> : null}
|
||||
|
||||
Reference in New Issue
Block a user