feat(*): 创作配置 - 支持 Midjourney、多图保存与自定义公告

Co-authored-by: ddcat666 <26564566+ddcat666@users.noreply.github.com>
This commit is contained in:
DAO-QIN
2026-10-04 10:43:31 +08:00
committed by ddcat666
parent 002a45f0c3
commit 853c7d2ce7
107 changed files with 9261 additions and 408 deletions

View File

@@ -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>
);

View File

@@ -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}
</>
);
}

View File

@@ -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" }}>

View 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;
}
}

View 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>
);
}

View File

@@ -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");