feat(*): 创作配置 - 支持 Midjourney、多图保存与自定义公告
Co-authored-by: ddcat666 <26564566+ddcat666@users.noreply.github.com>
This commit is contained in:
@@ -4,10 +4,15 @@ import ReactMarkdown from "react-markdown";
|
||||
|
||||
import { AppModal } from "@/components/ui/product/app-modal/app-modal";
|
||||
import { aceternityMotion } from "@/lib/aceternity-motion";
|
||||
import { useAppearanceStore } from "@/stores/use-appearance-store";
|
||||
import { updateAnnouncementVersion } from "@/lib/update-announcement";
|
||||
import { UpdateAnnouncementContent } from "./update-announcement-content";
|
||||
|
||||
export function AppChangelogDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
const reducedMotion = useReducedMotion();
|
||||
const version = `v${__APP_VERSION__.replace(/^v/, "")}`;
|
||||
const updates = useAppearanceStore((state) => state.appearance.updates);
|
||||
const custom = Boolean(updates?.enabled);
|
||||
const version = updateAnnouncementVersion(updates, __APP_VERSION__);
|
||||
|
||||
return (
|
||||
<AppModal
|
||||
@@ -18,8 +23,8 @@ export function AppChangelogDialog({ open, onClose }: { open: boolean; onClose:
|
||||
<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 className="app-changelog-heading-title">{custom ? "更新公告" : "更新日志"}</div>
|
||||
<div className="app-changelog-heading-description">{custom ? "按版本查看本站发布的更新与功能介绍" : "按版本查看产品能力、交互与稳定性变化"}</div>
|
||||
</div>
|
||||
<span className="app-changelog-current-version">当前版本 {version}</span>
|
||||
</div>
|
||||
@@ -36,29 +41,33 @@ export function AppChangelogDialog({ open, onClose }: { open: boolean; onClose:
|
||||
)}
|
||||
>
|
||||
<div className="app-changelog-scroll thin-scrollbar">
|
||||
<ReactMarkdown
|
||||
components={{
|
||||
h1: () => null,
|
||||
h2: ({ children }) => {
|
||||
const label = String(children);
|
||||
const latest = label === "Unreleased";
|
||||
{custom && updates ? (
|
||||
<UpdateAnnouncementContent value={updates} />
|
||||
) : (
|
||||
<ReactMarkdown
|
||||
components={{
|
||||
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__}
|
||||
</ReactMarkdown>
|
||||
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__}
|
||||
</ReactMarkdown>
|
||||
)}
|
||||
</div>
|
||||
</AppModal>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { ScrollText } from "lucide-react";
|
||||
import { lazy, Suspense, useState, type CSSProperties, type ReactNode } from "react";
|
||||
import { useAppearanceStore } from "@/stores/use-appearance-store";
|
||||
import { updateAnnouncementVersion } from "@/lib/update-announcement";
|
||||
import { refreshPublicAppearance } from "@/services/appearance-bootstrap";
|
||||
|
||||
const AppChangelogDialog = lazy(() => import("@/components/layout/app-changelog-dialog").then((module) => ({ default: module.AppChangelogDialog })));
|
||||
|
||||
@@ -16,17 +19,37 @@ type AppChangelogButtonProps = {
|
||||
label?: ReactNode;
|
||||
};
|
||||
|
||||
export function AppChangelogButton({ className, style, showVersion = false, showLabel = false, labelClassName, versionClassName, icon, label = "更新日志" }: AppChangelogButtonProps) {
|
||||
export function AppChangelogButton({ className, style, showVersion = false, showLabel = false, labelClassName, versionClassName, icon, label }: AppChangelogButtonProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const updates = useAppearanceStore((state) => state.appearance.updates);
|
||||
const title = updates?.enabled ? "更新公告" : "更新日志";
|
||||
|
||||
return (
|
||||
<>
|
||||
<button type="button" className={className} style={style} onClick={() => setOpen(true)} aria-label="查看更新日志" title="更新日志">
|
||||
<button
|
||||
type="button"
|
||||
className={className}
|
||||
style={style}
|
||||
onClick={() => {
|
||||
void refreshPublicAppearance();
|
||||
setOpen(true);
|
||||
}}
|
||||
aria-label={`查看${title}`}
|
||||
title={title}
|
||||
>
|
||||
{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}
|
||||
{showLabel ? <span className={`whitespace-nowrap ${labelClassName || ""}`}>{label ?? title}</span> : null}
|
||||
{showVersion ? (
|
||||
<span className={`${versionClassName || ""} min-w-0 truncate`} title={updateAnnouncementVersion(updates, APP_VERSION)}>
|
||||
{updateAnnouncementVersion(updates, APP_VERSION)}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
{open ? <Suspense fallback={null}><AppChangelogDialog open onClose={() => setOpen(false)} /></Suspense> : null}
|
||||
{open ? (
|
||||
<Suspense fallback={null}>
|
||||
<AppChangelogDialog open onClose={() => setOpen(false)} />
|
||||
</Suspense>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { lazy, Suspense, useLayoutEffect } from "react";
|
||||
import { lazy, Suspense, useEffect, useLayoutEffect } from "react";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { App, ConfigProvider } from "antd";
|
||||
import zhCN from "antd/locale/zh_CN";
|
||||
@@ -13,13 +13,18 @@ import { isIsolatedDirectorRepro } from "@/lib/dev-repro";
|
||||
import { useActiveTheme } from "@/stores/canvas/use-canvas-theme-store";
|
||||
import { applyAppearanceMetadata, useAppearanceStore } from "@/stores/use-appearance-store";
|
||||
import { useUserStore } from "@/stores/use-user-store";
|
||||
import { refreshPublicAppearance } from "@/services/appearance-bootstrap";
|
||||
|
||||
const ClientRootInit = lazy(() => import("@/components/layout/client-root-init").then((module) => ({ default: module.ClientRootInit })));
|
||||
|
||||
function ClientRootBoundary({ children }: { children: ReactNode }) {
|
||||
const authenticated = useUserStore((state) => Boolean(state.user));
|
||||
if (!authenticated) return children;
|
||||
return <Suspense fallback={<FullScreenLoader label="正在准备创作环境" detail="连接本地能力与模型配置" />}><ClientRootInit>{children}</ClientRootInit></Suspense>;
|
||||
return (
|
||||
<Suspense fallback={<FullScreenLoader label="正在准备创作环境" detail="连接本地能力与模型配置" />}>
|
||||
<ClientRootInit>{children}</ClientRootInit>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
export function AppProviders({ children }: { children: ReactNode }) {
|
||||
@@ -39,6 +44,19 @@ export function AppProviders({ children }: { children: ReactNode }) {
|
||||
// 与启动入口共用精确路径边界;生产构建中 import.meta.env.DEV 为 false,始终不启用隔离。
|
||||
const isolateDevRepro = typeof window !== "undefined" && isIsolatedDirectorRepro(import.meta.env.DEV, window.location.pathname);
|
||||
|
||||
useEffect(() => {
|
||||
if (isolateDevRepro) return;
|
||||
const refresh = () => {
|
||||
if (document.visibilityState === "visible") void refreshPublicAppearance();
|
||||
};
|
||||
window.addEventListener("focus", refresh);
|
||||
document.addEventListener("visibilitychange", refresh);
|
||||
return () => {
|
||||
window.removeEventListener("focus", refresh);
|
||||
document.removeEventListener("visibilitychange", refresh);
|
||||
};
|
||||
}, [isolateDevRepro]);
|
||||
|
||||
return (
|
||||
<ConfigProvider locale={zhCN} theme={getAntThemeConfig(dark, appearance.activeSkin)} wave={{ disabled: true }}>
|
||||
<App message={{ duration: 3, maxCount: 3 }} notification={{ duration: 4.5, maxCount: 3, placement: "topRight" }}>
|
||||
|
||||
121
web/src/components/layout/update-announcement-content.css
Normal file
121
web/src/components/layout/update-announcement-content.css
Normal file
@@ -0,0 +1,121 @@
|
||||
.update-announcement-content {
|
||||
display: grid;
|
||||
gap: 28px;
|
||||
min-width: 0;
|
||||
color: inherit;
|
||||
}
|
||||
.update-announcement-release {
|
||||
min-width: 0;
|
||||
}
|
||||
.update-announcement-release + .update-announcement-release {
|
||||
padding-top: 24px;
|
||||
border-top: 1px solid currentColor;
|
||||
border-color: color-mix(in srgb, currentColor 15%, transparent);
|
||||
}
|
||||
.update-announcement-release-heading {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.update-announcement-release-heading h3 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 650;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.update-announcement-release-heading time {
|
||||
margin-left: auto;
|
||||
font-size: 12px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.update-announcement-badge {
|
||||
padding: 2px 8px;
|
||||
border: 1px solid currentColor;
|
||||
border-color: color-mix(in srgb, currentColor 18%, transparent);
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
}
|
||||
.update-announcement-release-title {
|
||||
margin: 0 0 16px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.update-announcement-block-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 18px;
|
||||
align-items: start;
|
||||
}
|
||||
.update-announcement-block {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.update-announcement-block.is-full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.update-announcement-block figure {
|
||||
margin: 0;
|
||||
}
|
||||
.update-announcement-block img,
|
||||
.update-announcement-block video {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
max-height: 420px;
|
||||
object-fit: contain;
|
||||
border-radius: 10px;
|
||||
}
|
||||
.update-announcement-block figcaption {
|
||||
padding-top: 8px;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
opacity: 0.65;
|
||||
}
|
||||
.update-announcement-copy {
|
||||
font-size: 14px;
|
||||
line-height: 1.85;
|
||||
}
|
||||
.update-announcement-copy > :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
.update-announcement-copy > :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.update-announcement-copy p {
|
||||
margin: 0 0 10px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.update-announcement-copy ul,
|
||||
.update-announcement-copy ol {
|
||||
margin: 0 0 10px;
|
||||
padding-left: 22px;
|
||||
}
|
||||
.update-announcement-copy pre {
|
||||
overflow: auto;
|
||||
padding: 12px;
|
||||
background: color-mix(in srgb, currentColor 6%, transparent);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.update-announcement-copy a {
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
.update-announcement-media-empty,
|
||||
.update-announcement-empty {
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
opacity: 0.6;
|
||||
background: color-mix(in srgb, currentColor 5%, transparent);
|
||||
border-radius: 10px;
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.update-announcement-block-grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
.update-announcement-release-heading time {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
51
web/src/components/layout/update-announcement-content.tsx
Normal file
51
web/src/components/layout/update-announcement-content.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import type { UpdateAnnouncement, UpdateBlock } from "@/lib/update-announcement";
|
||||
import "./update-announcement-content.css";
|
||||
|
||||
export function UpdateAnnouncementContent({ value, mediaURL }: { value: UpdateAnnouncement; mediaURL?: (block: UpdateBlock) => string }) {
|
||||
return (
|
||||
<div className="update-announcement-content">
|
||||
{value.releases.length ? (
|
||||
value.releases.map((release) => (
|
||||
<section className="update-announcement-release" key={release.id} aria-label={`版本 ${release.version}`}>
|
||||
<header className="update-announcement-release-heading">
|
||||
<h3>{release.version || "待填写版本"}</h3>
|
||||
{release.version === value.currentVersion ? <span className="update-announcement-badge">当前版本</span> : null}
|
||||
{release.date ? <time dateTime={release.date}>{release.date}</time> : null}
|
||||
</header>
|
||||
{release.title ? <h4 className="update-announcement-release-title">{release.title}</h4> : null}
|
||||
<div className="update-announcement-block-grid">
|
||||
{release.blocks.map((block) => {
|
||||
const url = mediaURL?.(block) || block.mediaUrl || "";
|
||||
return (
|
||||
<div className={`update-announcement-block is-${block.layout}`} key={block.id}>
|
||||
{block.type === "text" ? (
|
||||
<div className="update-announcement-copy">
|
||||
<ReactMarkdown components={{ img: () => null }}>{block.text || "待填写文字内容"}</ReactMarkdown>
|
||||
</div>
|
||||
) : (
|
||||
<figure>
|
||||
{url ? (
|
||||
block.type === "image" ? (
|
||||
<img src={url} alt={block.caption || "更新公告配图"} loading="lazy" />
|
||||
) : (
|
||||
<video src={url} controls playsInline preload="metadata" aria-label={block.caption || "更新公告视频"} />
|
||||
)
|
||||
) : (
|
||||
<div className="update-announcement-media-empty">待上传{block.type === "image" ? "图片" : "视频"}</div>
|
||||
)}
|
||||
{block.caption ? <figcaption>{block.caption}</figcaption> : null}
|
||||
</figure>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
))
|
||||
) : (
|
||||
<p className="update-announcement-empty">添加版本与内容后,可在此预览公告。</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -40,7 +40,7 @@ function toolItem(slug: NavigationToolSlug, to: string): WorkspaceNavItem {
|
||||
return { id: slug, title: tool?.label ?? slug, icon: tool?.icon, to };
|
||||
}
|
||||
|
||||
function buildNav(features: FeatureAvailability, isAdmin: boolean): { groups: WorkspaceNavGroup[]; footer: WorkspaceNavItem[] } {
|
||||
function buildNav(features: FeatureAvailability): { groups: WorkspaceNavGroup[]; footer: WorkspaceNavItem[] } {
|
||||
const groups: WorkspaceNavGroup[] = [
|
||||
{
|
||||
items: [
|
||||
@@ -51,7 +51,7 @@ function buildNav(features: FeatureAvailability, isAdmin: boolean): { groups: Wo
|
||||
},
|
||||
{
|
||||
heading: "资源与工具",
|
||||
items: [{ ...toolItem("assets", "/assets"), title: "资产" }, { ...toolItem("skills", "/skills"), title: "技能" }, ...(features.pluginCenterEnabled || isAdmin ? [{ ...toolItem("plugins", "/plugins"), title: "插件" }] : [])],
|
||||
items: [{ ...toolItem("assets", "/assets"), title: "资产" }, { ...toolItem("skills", "/skills"), title: "技能" }, ...(features.pluginCenterEnabled ? [{ ...toolItem("plugins", "/plugins"), title: "插件" }] : [])],
|
||||
},
|
||||
...(features.taskCenterEnabled ? [{ items: [{ ...toolItem("tasks", "/tasks"), title: "创作历史", icon: HistoryIcon }] }] : []),
|
||||
];
|
||||
@@ -290,7 +290,7 @@ export function WorkspaceSidebarNav({ collapsed, onNavigate, onOpenSearch, onExp
|
||||
const features = useUserStore((state) => state.features);
|
||||
const user = useUserStore((state) => state.user);
|
||||
const { handleLogout } = useWorkspaceLogout();
|
||||
const { groups, footer } = useMemo(() => buildNav(features, user?.role === "admin"), [features, user?.role]);
|
||||
const { groups, footer } = useMemo(() => buildNav(features), [features]);
|
||||
|
||||
const slug = pathname.split("/").filter(Boolean)[0] || "home";
|
||||
const section = searchParams.get("section");
|
||||
|
||||
Reference in New Issue
Block a user