feat(admin): 管理后台 - 统一桌面端信息架构与视觉规范

This commit is contained in:
HANG LU
2026-08-30 05:05:46 +08:00
parent d89f170a08
commit 10cebf8c69
32 changed files with 17070 additions and 1860 deletions

View File

@@ -1,7 +1,7 @@
import { Modal, Tag } from "antd";
import { Modal } from "antd";
import { motion, useReducedMotion } from "motion/react";
import { ScrollText } from "lucide-react";
import { useState, type CSSProperties } from "react";
import { useState, type CSSProperties, type ReactNode } from "react";
import ReactMarkdown from "react-markdown";
import { aceternityMotion } from "@/lib/aceternity-motion";
@@ -15,16 +15,18 @@ type AppChangelogButtonProps = {
showLabel?: boolean;
labelClassName?: string;
versionClassName?: string;
icon?: ReactNode;
label?: ReactNode;
};
export function AppChangelogButton({ className, style, showVersion = false, showLabel = false, labelClassName, versionClassName }: AppChangelogButtonProps) {
export function AppChangelogButton({ className, style, showVersion = false, showLabel = false, labelClassName, versionClassName, icon, label = "更新日志" }: AppChangelogButtonProps) {
const [open, setOpen] = useState(false);
return (
<>
<button type="button" className={className} style={style} onClick={() => setOpen(true)} aria-label="查看更新日志" title="更新日志">
<ScrollText className="size-4 shrink-0" />
{showLabel ? <span className={`whitespace-nowrap ${labelClassName || ""}`}>更新日志</span> : null}
{icon ?? <ScrollText className="size-4 shrink-0" />}
{showLabel ? <span className={`whitespace-nowrap ${labelClassName || ""}`}>{label}</span> : null}
{showVersion ? <span className={versionClassName}>v{APP_VERSION.replace(/^v/, "")}</span> : null}
</button>
<AppChangelogModal open={open} onClose={() => setOpen(false)} />
@@ -34,32 +36,55 @@ export function AppChangelogButton({ className, style, showVersion = false, show
function AppChangelogModal({ open, onClose }: { open: boolean; onClose: () => void }) {
const reducedMotion = useReducedMotion();
const version = `v${APP_VERSION.replace(/^v/, "")}`;
return (
<Modal
rootClassName="app-spatial-modal"
title={<div className="flex min-w-0 items-center gap-3 pr-8"><span className="grid size-9 shrink-0 place-items-center rounded-full border border-border bg-muted/45"><ScrollText className="size-4" /></span><div className="min-w-0"><div className="flex items-center gap-2 text-base font-semibold">更新日志<Tag variant="filled">v{APP_VERSION.replace(/^v/, "")}</Tag></div><div className="mt-0.5 text-xs font-normal text-foreground/45">产品能力、交互与稳定性变化</div></div></div>}
rootClassName="app-spatial-modal app-changelog-modal"
title={
<div className="app-changelog-heading">
<span className="app-changelog-heading-icon">
<ScrollText className="size-4" />
</span>
<div className="app-changelog-heading-copy">
<div className="app-changelog-heading-title">更新日志</div>
<div className="app-changelog-heading-description">按版本查看产品能力、交互与稳定性变化</div>
</div>
<span className="app-changelog-current-version">当前版本 {version}</span>
</div>
}
open={open}
width={760}
width={820}
footer={null}
centered
onCancel={onClose}
styles={{ body: { maxHeight: "min(72vh, 760px)", overflowY: "auto", overscrollBehavior: "contain" } }}
styles={{ body: { padding: 0 } }}
modalRender={(node) => (
<motion.div initial={reducedMotion ? false : { opacity: 0, y: 14, scale: 0.975 }} animate={{ opacity: 1, y: 0, scale: 1 }} transition={{ duration: aceternityMotion.duration.panel, ease: aceternityMotion.easing.enter }}>
{node}
</motion.div>
)}
>
<div className="thin-scrollbar pr-2 text-sm leading-6 text-foreground/75">
<div className="app-changelog-scroll thin-scrollbar">
<ReactMarkdown
components={{
h1: ({ children }) => <h2 className="mb-4 text-xl font-semibold text-foreground">{children}</h2>,
h2: ({ children }) => <h3 className="mb-2 mt-6 border-b border-border pb-2 text-base font-semibold text-foreground first:mt-0">{children}</h3>,
ul: ({ children }) => <ul className="space-y-2 pl-5">{children}</ul>,
li: ({ children }) => <li className="list-disc pl-1 marker:text-foreground/35">{children}</li>,
p: ({ children }) => <p className="my-2">{children}</p>,
code: ({ children }) => <code className="rounded bg-muted px-1 py-0.5 text-xs text-foreground">{children}</code>,
h1: () => null,
h2: ({ children }) => {
const label = String(children);
const latest = label === "Unreleased";
return (
<h3 className={`app-changelog-section-heading${latest ? " is-latest" : ""}`}>
<span className="app-changelog-section-marker" aria-hidden="true" />
<span>{latest ? "开发中" : label}</span>
{latest ? <span className="app-changelog-latest-badge">最新</span> : null}
</h3>
);
},
ul: ({ children }) => <ul className="app-changelog-list">{children}</ul>,
li: ({ children }) => <li>{children}</li>,
p: ({ children }) => <p className="app-changelog-paragraph">{children}</p>,
code: ({ children }) => <code className="app-changelog-code">{children}</code>,
}}
>
{__APP_CHANGELOG__}

View File

@@ -1,4 +1,5 @@
export const WORKSPACE_SIDEBAR_STORAGE_KEY = "infinite-canvas:workspace-sidebar-collapsed";
export const WORKSPACE_SIDEBAR_CHANGE_EVENT = "workspace:sidebar-collapsed-change";
type WorkspaceSidebarStorage = Pick<Storage, "getItem" | "setItem">;
@@ -27,3 +28,21 @@ export function writeWorkspaceSidebarCollapsed(collapsed: boolean, storage?: Wor
// localStorage 不可用时保留当前内存状态,不能阻断侧栏交互。
}
}
export function publishWorkspaceSidebarCollapsed(collapsed: boolean) {
writeWorkspaceSidebarCollapsed(collapsed);
window.dispatchEvent(new CustomEvent(WORKSPACE_SIDEBAR_CHANGE_EVENT, { detail: { collapsed } }));
}
export function subscribeWorkspaceSidebarCollapsed(onChange: (collapsed: boolean) => void) {
const handleChange = (event: Event) => onChange(Boolean((event as CustomEvent<{ collapsed?: boolean }>).detail?.collapsed));
const handleStorage = (event: StorageEvent) => {
if (event.key === WORKSPACE_SIDEBAR_STORAGE_KEY) onChange(event.newValue === "1");
};
window.addEventListener(WORKSPACE_SIDEBAR_CHANGE_EVENT, handleChange);
window.addEventListener("storage", handleStorage);
return () => {
window.removeEventListener(WORKSPACE_SIDEBAR_CHANGE_EVENT, handleChange);
window.removeEventListener("storage", handleStorage);
};
}