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

@@ -158,7 +158,7 @@ function AdminShellLayout() {
<main id="admin-root" data-admin-root data-admin-density={density} className="admin-shell flex h-full min-h-0 overflow-hidden">
<aside className={cn("admin-sidebar hidden shrink-0 flex-col overflow-hidden lg:flex", collapsed && "is-collapsed")}>
<div className="admin-sidebar-identity shrink-0">
<AdminTooltip title={collapsed ? "查看更新日志" : undefined} placement="right">
<AdminTooltip title={collapsed ? (appearance.updates?.enabled ? "查看更新公告" : "查看更新日志") : undefined} placement="right">
<AppChangelogButton
className={cn("admin-sidebar-brand-button", collapsed && "is-collapsed")}
icon={<BrandLogoFrame className="admin-sidebar-brand-mark grid shrink-0 place-items-center bg-foreground text-background" logoClassName="size-5 object-contain" alt="" fallback={<InfinityIcon className="size-4" />} />}

View File

@@ -1,5 +1,6 @@
import type { ModelCapabilityChoice } from "@/components/model-protocol-picker";
import { defaultModelCapabilityConfig, normalizeModelCapabilityConfig, type ModelCapabilityConfig } from "@/lib/model-capabilities";
import { imagePresetForRatio, imageSizePresets } from "@/lib/image-size-presets";
import { modelProtocolSupportsTokenBilling, type ModelProtocolDefinition } from "@/lib/model-protocols";
import type { ChannelModel } from "@/services/api/wallet";
import type { ModelTag } from "@/lib/model-tags";
@@ -67,6 +68,25 @@ export function changeChannelModelCapability(values: ChannelModelFormValues, pro
};
}
export function updateChannelModelUpstreamCapabilities(values: ChannelModelFormValues): ChannelModelFormValues {
if (values.capability !== "image" || values.protocol !== "cangyuan-midjourney-v82") return values;
const defaults = defaultModelCapabilityConfig(values.protocol, values.providerModelKey?.trim() || values.modelKey.trim());
const image = values.capabilityConfig?.image;
if (!image) return { ...values, capabilityConfig: defaults };
if (image.size.parameter !== "aspect_ratio") return values;
const tier = defaults.image!.size.presets![0].tier;
const presets = imageSizePresets(image);
if (presets.length && presets.every((preset) => preset.tier === tier)) return values;
const ratios = Array.from(new Set(presets.map((preset) => preset.ratio)));
return {
...values,
capabilityConfig: {
...values.capabilityConfig!,
image: { ...image, size: { ...image.size, presets: ratios.length ? ratios.map((ratio) => imagePresetForRatio(tier, ratio)) : defaults.image!.size.presets } },
},
};
}
export function validateChannelModelProtocol(capability: string, protocol: string | undefined, protocols: ModelProtocolDefinition[]) {
if (!protocols.some((p) => p.value === protocol && p.capability === capability && p.enabled !== false)) {
throw new Error("请选择当前能力下已启用的请求协议");

View File

@@ -14,7 +14,7 @@ import type { ModelChannel } from "@/stores/use-config-store";
import { defaultPriceTier, normalizeUpstreamModelKey, priceTierPayloadFromForm } from "./channel-model-price-tier-form";
import { PriceTierFields } from "./channel-model-price-tier-fields";
import { ChannelModelTagsEditor } from "./channel-model-tags-editor";
import { changeChannelModelCapability, editorSectionForField, initialChannelModelValues, validateChannelModelPrices, validateChannelModelProtocol, type ChannelModelFormValues as FormValues, type EditorSection } from "./channel-model-editor-form";
import { changeChannelModelCapability, editorSectionForField, initialChannelModelValues, updateChannelModelUpstreamCapabilities, validateChannelModelPrices, validateChannelModelProtocol, type ChannelModelFormValues as FormValues, type EditorSection } from "./channel-model-editor-form";
export function ChannelModelEditor({
channel,
@@ -77,6 +77,13 @@ export function ChannelModelEditor({
} else if (changed.protocol) {
form.setFieldValue("capabilityConfig", modelCapability === "audio" ? undefined : defaultModelCapabilityConfig(changed.protocol, providerModelKey.trim() || modelKey.trim()));
setConfigurationChanged(true);
} else if (changed.providerModelKey !== undefined || changed.modelKey !== undefined) {
const values = form.getFieldsValue(true);
const next = updateChannelModelUpstreamCapabilities(values);
if (next !== values) {
form.setFieldValue("capabilityConfig", next.capabilityConfig);
setConfigurationChanged(true);
}
}
};
@@ -333,6 +340,7 @@ export function ChannelModelEditor({
model={providerModelKey || modelKey}
protocol={form.getFieldValue("protocol")}
section="references"
workflows={protocols.find((item) => item.value === modelProtocol)?.workflows}
value={capabilityConfig}
onChange={(next) => {
dirtyRef.current = true;
@@ -351,6 +359,7 @@ export function ChannelModelEditor({
model={providerModelKey || modelKey}
protocol={form.getFieldValue("protocol")}
section="protocol"
workflows={protocols.find((item) => item.value === modelProtocol)?.workflows}
value={capabilityConfig}
onChange={(next) => {
dirtyRef.current = true;

View File

@@ -38,7 +38,7 @@ export function PriceTierFields({
// 静默改变实际发往供应商的模型,必须显式提示并允许一键恢复“跟随模型默认”。
const staleTierUpstream = matchMode === "default" && tierUpstream && modelUpstream && tierUpstream !== modelUpstream ? tierUpstream : "";
const video = capabilityConfig?.video;
const resolutionOptions = video?.resolutions || [];
const resolutionOptions = Array.from(new Set([...(video?.resolutions || []), ...(protocol === "autodl-comfyui" ? video?.fixedScreenSpec?.resolutions || [] : [])]));
const tokenEnabled = modelProtocolSupportsTokenBilling(capability, protocol);
const isVideo = capability === "video";
const isAudio = capability === "audio";

View File

@@ -8,6 +8,8 @@ import { Switch } from "@/pages/admin/ui/controls";
import { WelcomeSetting } from "@/pages/admin/settings/components/welcome-setting";
import { SkinThemeEditor } from "@/pages/admin/settings/components/skin-theme-editor";
import { CanvasAppearanceEditor } from "./components/canvas-appearance-editor";
import { UpdateAnnouncementEditor } from "./components/update-announcement-editor";
import { DEFAULT_UPDATE_ANNOUNCEMENT, normalizeUpdateAnnouncement, validateUpdateAnnouncement, type UpdateAnnouncement } from "@/lib/update-announcement";
import { App, Button, Tabs, Form, Input } from "antd";
import { useCallback, useEffect, useRef, useState } from "react";
import { useBlocker } from "react-router";
@@ -28,6 +30,7 @@ export default function AppearanceSettingsPage() {
const [setting, setSetting] = useState<AdminAppearance | null>(null);
const [brandName, setBrandName] = useState("");
const [canvas, setCanvas] = useState<CanvasAppearance>(DEFAULT_CANVAS_APPEARANCE);
const [updates, setUpdates] = useState<UpdateAnnouncement>(DEFAULT_UPDATE_ANNOUNCEMENT);
const [canvasUploading, setCanvasUploading] = useState(false);
const [brandSlug, setBrandSlug] = useState("");
const [authHeroTitle, setAuthHeroTitle] = useState("");
@@ -59,7 +62,10 @@ export default function AppearanceSettingsPage() {
const dirty =
Boolean(setting) &&
(JSON.stringify(canvas) !== JSON.stringify(setting?.canvas || DEFAULT_CANVAS_APPEARANCE) || canvasUploading || brandName.trim() !== setting?.brandName ||
(JSON.stringify(updates) !== JSON.stringify(normalizeUpdateAnnouncement(setting?.updates)) ||
JSON.stringify(canvas) !== JSON.stringify(setting?.canvas || DEFAULT_CANVAS_APPEARANCE) ||
canvasUploading ||
brandName.trim() !== setting?.brandName ||
brandSlug.trim().toLocaleLowerCase() !== setting?.brandSlug ||
normalizeDraftCopy(authHeroTitle) !== setting?.authHeroTitle ||
normalizeDraftCopy(authHeroDescription) !== setting?.authHeroDescription ||
@@ -83,6 +89,7 @@ export default function AppearanceSettingsPage() {
setSetting({ ...value, skinThemes: themes, skinId: selectedID });
setBrandName(value.brandName);
setCanvas(value.canvas || DEFAULT_CANVAS_APPEARANCE);
setUpdates(normalizeUpdateAnnouncement(value.updates));
setBrandSlug(value.brandSlug);
setAuthHeroTitle(value.authHeroTitle);
setAuthHeroDescription(value.authHeroDescription);
@@ -135,7 +142,7 @@ export default function AppearanceSettingsPage() {
if (blocker.state !== "blocked") return;
modal.confirm({
title: "放弃站点及外观调整?",
content: "当前品牌、画布 Agent、SEO、备案、皮肤或媒体配置尚未保存,离开后草稿会丢失。线上站点不会改变。",
content: "当前品牌、画布 Agent、更新公告、SEO、备案、皮肤或媒体配置尚未保存,离开后草稿会丢失。线上站点不会改变。",
okText: "放弃并离开",
cancelText: "继续编辑",
okButtonProps: { danger: true },
@@ -192,6 +199,7 @@ export default function AppearanceSettingsPage() {
const discardDraft = () => {
if (!setting || saving || restoring || canvasUploading) return;
setCanvas(setting.canvas || DEFAULT_CANVAS_APPEARANCE);
setUpdates(normalizeUpdateAnnouncement(setting.updates));
setBrandName(setting.brandName);
setBrandSlug(setting.brandSlug);
setAuthHeroTitle(setting.authHeroTitle);
@@ -222,7 +230,7 @@ export default function AppearanceSettingsPage() {
}
modal.confirm({
title: "放弃调整并重新读取?",
content: "重新读取会丢弃当前品牌、画布 Agent、SEO、备案、皮肤和待上传文件。",
content: "重新读取会丢弃当前品牌、画布 Agent、更新公告、SEO、备案、皮肤和待上传文件。",
okText: "放弃并刷新",
cancelText: "继续编辑",
okButtonProps: { danger: true },
@@ -234,7 +242,7 @@ export default function AppearanceSettingsPage() {
if (!setting?.configured || saving || refreshing || restoring || canvasUploading) return;
modal.confirm({
title: "恢复影策默认品牌标识?",
content: "品牌名称、英文标识、Logo、画布 Agent 名称/文案/形象、登录页文案、视频、封面、SEO、备案和皮肤主题会立即恢复为项目内置值。已上传文件仍保留在存储资源中,不会被删除。",
content: "品牌名称、英文标识、Logo、画布 Agent 名称/文案/形象、更新公告、登录页文案、视频、封面、SEO、备案和皮肤主题会立即恢复为项目内置值;自定义公告会清空并关闭,展示原有更新日志。已上传文件仍保留在存储资源中,不会被删除。",
okText: "恢复默认",
cancelText: "取消",
okButtonProps: { danger: true },
@@ -261,6 +269,12 @@ export default function AppearanceSettingsPage() {
const save = async () => {
if (!setting || saving || restoring || canvasUploading) return;
const updatesError = validateUpdateAnnouncement(updates);
if (updatesError) {
setActiveTab("updates");
message.error(updatesError);
return;
}
if (![canvas.agentName, canvas.panelTitle, canvas.welcomeTitle, canvas.inputPlaceholder].every((text) => text.trim())) {
setActiveTab("canvas");
message.error("请填写助手名称、面板标题、欢迎标题和输入框提示");
@@ -337,6 +351,7 @@ export default function AppearanceSettingsPage() {
}
const updated = await updateAdminAppearance({
canvas,
updates,
brandName: nextBrandName,
brandSlug: nextBrandSlug,
authHeroTitle: nextAuthHeroTitle,
@@ -445,7 +460,7 @@ export default function AppearanceSettingsPage() {
<strong>{dirty ? "站点配置有调整待保存" : "站点及外观已与服务端同步"}</strong>
<AdminStatusBadge label={dirty ? "尚未生效" : "服务端当前值"} tone={dirty ? "warning" : "neutral"} />
</div>
<p>{dirty ? "切换分类保留草稿;保存修改会一次应用品牌、登录页、SEO、备案和皮肤调整。" : "按分类管理站点配置;欢迎页开关独立即时保存,其余修改统一保存。"}</p>
<p>{dirty ? "切换分类保留草稿;保存修改会一次应用品牌、登录页、更新公告、SEO、备案和皮肤调整。" : "按分类管理站点配置;欢迎页开关独立即时保存,其余修改统一保存。"}</p>
</div>
</div>
<div className="admin-appearance-command-actions">
@@ -650,12 +665,7 @@ export default function AppearanceSettingsPage() {
key: "welcome",
label: "欢迎页",
children: (
<SettingsSectionCard
className="admin-appearance-section"
icon={<Globe2 className="size-4" aria-hidden="true" />}
title="欢迎页"
description="控制访客是否可以访问欢迎页,开关修改后立即保存。"
>
<SettingsSectionCard className="admin-appearance-section" icon={<Globe2 className="size-4" aria-hidden="true" />} title="欢迎页" description="控制访客是否可以访问欢迎页,开关修改后立即保存。">
<div className="admin-appearance-section-form">
<WelcomeSetting />
</div>
@@ -761,7 +771,8 @@ export default function AppearanceSettingsPage() {
</SettingsSectionCard>
),
},
{ key: "canvas", label: "画布配置", children: <CanvasAppearanceEditor value={canvas} onChange={setCanvas} disabled={saving || refreshing || restoring} onUploading={setCanvasUploading} /> },
{ key: "canvas", label: "画布配置", children: <CanvasAppearanceEditor value={canvas} onChange={setCanvas} disabled={saving || refreshing || restoring || canvasUploading} onUploading={setCanvasUploading} /> },
{ key: "updates", label: "更新公告", children: <UpdateAnnouncementEditor value={updates} onChange={setUpdates} disabled={saving || refreshing || restoring || canvasUploading} onUploading={setCanvasUploading} /> },
]}
/>
</div>

View File

@@ -42,7 +42,7 @@ export function CanvasAppearanceEditor({ value, onChange, disabled, onUploading
<div className="grid gap-6 lg:grid-cols-2">
<div>
<h3 className="mb-3 font-semibold">Agent 身份与文案</h3>
<p className="mb-4 text-sm text-foreground/60">独立于站点品牌。文案可使用 {"{agentName}"} 引用助手名称;这里只配置展示文字,不修改工具权限。</p>
<p className="mb-4 text-sm text-foreground/60">独立于站点品牌。助手名称同步用于界面文案和对话中的自我介绍;文案可使用 {"{agentName}"} 引用名称,不修改工具权限。</p>
<Form layout="vertical" disabled={disabled || uploading}>
{(
[

View File

@@ -0,0 +1,109 @@
[data-admin-root] .admin-update-announcement-editor {
display: grid;
gap: 24px;
min-width: 0;
}
[data-admin-root] .admin-update-announcement-switch {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
}
[data-admin-root] .admin-update-announcement-switch h3 {
margin: 0 0 8px;
font-size: 16px;
font-weight: 600;
}
[data-admin-root] .admin-update-announcement-switch p {
margin: 0;
color: var(--admin-text-muted);
font-size: 13px;
line-height: 1.7;
}
[data-admin-root] .admin-update-announcement-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 24px;
align-items: start;
}
[data-admin-root] .admin-update-announcement-fields {
display: grid;
gap: 16px;
min-width: 0;
}
[data-admin-root] .admin-update-announcement-release {
display: grid;
gap: 16px;
padding: 20px;
border: 1px solid var(--admin-border);
border-radius: var(--admin-radius-lg);
}
[data-admin-root] .admin-update-announcement-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 10px;
}
[data-admin-root] .admin-update-announcement-toolbar > div,
[data-admin-root] .admin-update-announcement-add-actions {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
[data-admin-root] .admin-update-announcement-meta {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 12px;
}
[data-admin-root] .admin-update-announcement-block {
display: grid;
gap: 14px;
padding: 16px;
border: 1px solid var(--admin-border);
border-radius: var(--admin-radius-lg);
background: var(--admin-surface-2);
}
[data-admin-root] .admin-update-announcement-block .ant-form-item:last-child {
margin-bottom: 0;
}
[data-admin-root] .admin-update-announcement-preview {
position: sticky;
top: 0;
padding: 24px;
max-height: 72vh;
overflow: auto;
border: 1px solid var(--admin-border);
border-radius: var(--admin-radius-lg);
background: var(--admin-surface);
color: var(--admin-text);
}
[data-admin-root] .admin-update-announcement-preview-heading {
display: grid;
gap: 6px;
padding-bottom: 18px;
margin-bottom: 22px;
border-bottom: 1px solid var(--admin-border);
overflow-wrap: anywhere;
}
[data-admin-root] .admin-update-announcement-preview-heading span {
color: var(--admin-text-muted);
font-size: 12px;
}
@media (max-width: 1100px) {
[data-admin-root] .admin-update-announcement-layout {
grid-template-columns: minmax(0, 1fr);
}
[data-admin-root] .admin-update-announcement-preview {
position: static;
}
}
@media (max-width: 600px) {
[data-admin-root] .admin-update-announcement-meta {
grid-template-columns: minmax(0, 1fr);
}
[data-admin-root] .admin-update-announcement-release,
[data-admin-root] .admin-update-announcement-preview {
padding: 16px;
}
}

View File

@@ -0,0 +1,223 @@
import { useRef, useState } from "react";
import { App, Button, Form, Input, Select } from "antd";
import { ArrowDown, ArrowUp, ImagePlus, Plus, Trash2, Upload, Video, Type } from "lucide-react";
import { Switch } from "@/pages/admin/ui/controls";
import { UpdateAnnouncementContent } from "@/components/layout/update-announcement-content";
import { moveUpdateItem, type UpdateAnnouncement, type UpdateBlock, type UpdateRelease } from "@/lib/update-announcement";
import { updateAnnouncementPreviewURL, uploadAppearanceAsset } from "@/services/api/appearance";
import "./update-announcement-editor.css";
export function UpdateAnnouncementEditor({ value, onChange, disabled, onUploading }: { value: UpdateAnnouncement; onChange: (value: UpdateAnnouncement) => void; disabled: boolean; onUploading: (value: boolean) => void }) {
const { message } = App.useApp();
const [uploading, setUploading] = useState("");
const fileInput = useRef<HTMLInputElement>(null);
const target = useRef<{ releaseId: string; blockId: string; type: "image" | "video" } | null>(null);
const locked = disabled || Boolean(uploading);
const change = (patch: Partial<UpdateAnnouncement>) => onChange({ ...value, ...patch });
const changeRelease = (id: string, patch: Partial<UpdateRelease>) => {
const before = value.releases.find((release) => release.id === id);
change({ releases: value.releases.map((release) => (release.id === id ? { ...release, ...patch } : release)), ...(patch.version !== undefined && before?.version === value.currentVersion ? { currentVersion: patch.version } : {}) });
};
const changeBlock = (release: UpdateRelease, id: string, patch: Partial<UpdateBlock>) => changeRelease(release.id, { blocks: release.blocks.map((block) => (block.id === id ? { ...block, ...patch } : block)) });
const addBlock = (release: UpdateRelease, type: UpdateBlock["type"]) => changeRelease(release.id, { blocks: [...release.blocks, { id: crypto.randomUUID(), type, text: "", resourceId: "", caption: "", layout: "full" }] });
async function upload(file?: File) {
const selected = target.current;
if (!file || !selected) return;
const video = selected.type === "video";
const allowed = video ? ["video/mp4", "video/webm"] : ["image/png", "image/jpeg", "image/webp"];
const maxMB = video ? 256 : 10;
if (!allowed.includes(file.type) || file.size <= 0 || file.size > maxMB * 1024 * 1024) {
message.error(`请选择${video ? "MP4 / WebM 视频" : "PNG / JPEG / WebP 图片"},最大 ${maxMB}MB`);
return;
}
setUploading(selected.blockId);
onUploading(true);
try {
const resource = await uploadAppearanceAsset(video ? "update-video" : "update-image", file);
const release = value.releases.find((item) => item.id === selected.releaseId);
if (release) changeBlock(release, selected.blockId, { resourceId: resource.id });
message.success("媒体已上传,保存修改后随公告生效");
} catch (error) {
message.error(error instanceof Error ? error.message : "公告媒体上传失败");
} finally {
setUploading("");
onUploading(false);
target.current = null;
}
}
return (
<div className="admin-update-announcement-editor">
<div className="admin-update-announcement-switch">
<div>
<h3>自定义更新公告</h3>
<p>关闭时展示原有更新日志;开启时展示下方自定义版本与内容。修改后点击页面顶部“保存修改”生效。</p>
</div>
<Switch checked={value.enabled} disabled={locked} aria-label="启用自定义更新公告" onChange={(enabled) => change({ enabled })} />
</div>
<input
ref={fileInput}
type="file"
className="hidden"
onChange={(event) => {
void upload(event.target.files?.[0]);
event.currentTarget.value = "";
}}
/>
<div className="admin-update-announcement-layout">
<div className="admin-update-announcement-fields">
<Form layout="vertical" disabled={locked}>
<Form.Item label="当前展示版本" htmlFor="update-current-version">
<Select
id="update-current-version"
aria-label="当前展示版本"
placeholder="添加公告版本后选择"
value={value.currentVersion || undefined}
options={value.releases.filter((release) => release.version.trim()).map((release) => ({ value: release.version, label: release.version }))}
onChange={(currentVersion) => change({ currentVersion })}
/>
</Form.Item>
</Form>
{value.releases.map((release, index) => (
<section className="admin-update-announcement-release" key={release.id}>
<div className="admin-update-announcement-toolbar">
<strong>公告版本 {index + 1}</strong>
<div>
<Button aria-label={`上移公告版本 ${index + 1}`} icon={<ArrowUp className="size-4" />} disabled={locked || index === 0} onClick={() => change({ releases: moveUpdateItem(value.releases, index, -1) })} />
<Button
aria-label={`下移公告版本 ${index + 1}`}
icon={<ArrowDown className="size-4" />}
disabled={locked || index === value.releases.length - 1}
onClick={() => change({ releases: moveUpdateItem(value.releases, index, 1) })}
/>
<Button
aria-label={`删除公告版本 ${index + 1}`}
icon={<Trash2 className="size-4" />}
disabled={locked}
onClick={() => {
const releases = value.releases.filter((item) => item.id !== release.id);
change({ releases, currentVersion: release.version === value.currentVersion ? releases[0]?.version || "" : value.currentVersion });
}}
/>
</div>
</div>
<Form layout="vertical" disabled={locked}>
<div className="admin-update-announcement-meta">
<Form.Item label="版本号" htmlFor={`update-version-${release.id}`}>
<Input id={`update-version-${release.id}`} value={release.version} maxLength={40} placeholder="例如 v2.0.0" onChange={(event) => changeRelease(release.id, { version: event.target.value })} />
</Form.Item>
<Form.Item label="发布日期(可选)" htmlFor={`update-date-${release.id}`}>
<Input id={`update-date-${release.id}`} type="date" value={release.date} onChange={(event) => changeRelease(release.id, { date: event.target.value })} />
</Form.Item>
</div>
<Form.Item label="标题(可选)" htmlFor={`update-title-${release.id}`}>
<Input id={`update-title-${release.id}`} value={release.title} maxLength={100} placeholder="本次更新的主题" onChange={(event) => changeRelease(release.id, { title: event.target.value })} />
</Form.Item>
</Form>
{release.blocks.map((block, blockIndex) => (
<div className="admin-update-announcement-block" key={block.id}>
<div className="admin-update-announcement-toolbar">
<strong>
{block.type === "text" ? "文字" : block.type === "image" ? "图片" : "视频"} {blockIndex + 1}
</strong>
<div>
<Button
aria-label={`上移内容块 ${blockIndex + 1}(版本 ${index + 1})`}
icon={<ArrowUp className="size-4" />}
disabled={locked || blockIndex === 0}
onClick={() => changeRelease(release.id, { blocks: moveUpdateItem(release.blocks, blockIndex, -1) })}
/>
<Button
aria-label={`下移内容块 ${blockIndex + 1}(版本 ${index + 1})`}
icon={<ArrowDown className="size-4" />}
disabled={locked || blockIndex === release.blocks.length - 1}
onClick={() => changeRelease(release.id, { blocks: moveUpdateItem(release.blocks, blockIndex, 1) })}
/>
<Button
aria-label={`删除内容块 ${blockIndex + 1}(版本 ${index + 1})`}
icon={<Trash2 className="size-4" />}
disabled={locked}
onClick={() => changeRelease(release.id, { blocks: release.blocks.filter((item) => item.id !== block.id) })}
/>
</div>
</div>
<Form layout="vertical" disabled={locked}>
<Form.Item label="布局" htmlFor={`update-layout-${block.id}`}>
<Select
id={`update-layout-${block.id}`}
value={block.layout}
options={[
{ value: "full", label: "整行" },
{ value: "half", label: "半行(相邻半行内容并排)" },
]}
onChange={(layout) => changeBlock(release, block.id, { layout })}
/>
</Form.Item>
{block.type === "text" ? (
<Form.Item label="正文" htmlFor={`update-text-${block.id}`} extra="支持换行、列表和 Markdown 加粗。">
<Input.TextArea
id={`update-text-${block.id}`}
value={block.text}
maxLength={10000}
autoSize={{ minRows: 4, maxRows: 14 }}
onChange={(event) => changeBlock(release, block.id, { text: event.target.value })}
/>
</Form.Item>
) : (
<>
<Button
disabled={locked}
loading={uploading === block.id}
icon={<Upload className="size-4" />}
onClick={() => {
target.current = { releaseId: release.id, blockId: block.id, type: block.type as "image" | "video" };
if (fileInput.current) {
fileInput.current.accept = block.type === "image" ? "image/png,image/jpeg,image/webp" : "video/mp4,video/webm";
fileInput.current.click();
}
}}
>
{block.resourceId ? "更换" : "上传"}
{block.type === "image" ? "图片(最大 10MB)" : "视频(最大 256MB)"}
</Button>
<Form.Item label="媒体说明(可选)" htmlFor={`update-caption-${block.id}`}>
<Input id={`update-caption-${block.id}`} value={block.caption} maxLength={200} onChange={(event) => changeBlock(release, block.id, { caption: event.target.value })} />
</Form.Item>
</>
)}
</Form>
</div>
))}
<div className="admin-update-announcement-add-actions">
{(
[
["text", "添加文字", Type],
["image", "添加图片", ImagePlus],
["video", "添加视频", Video],
] as const
).map(([type, label, Icon]) => (
<Button key={type} disabled={locked || release.blocks.length >= 50} icon={<Icon className="size-4" />} onClick={() => addBlock(release, type)}>
{label}
</Button>
))}
</div>
</section>
))}
<Button
disabled={locked || value.releases.length >= 30}
icon={<Plus className="size-4" />}
onClick={() => change({ releases: [...value.releases, { id: crypto.randomUUID(), version: "", title: "", date: "", blocks: [{ id: crypto.randomUUID(), type: "text", text: "", resourceId: "", caption: "", layout: "full" }] }] })}
>
添加公告版本
</Button>
</div>
<aside className="admin-update-announcement-preview" aria-label="更新公告实时预览">
<div className="admin-update-announcement-preview-heading">
<strong>更新公告 · {value.currentVersion || "待填写版本"}</strong>
<span>实时预览 · 保存后生效</span>
</div>
<UpdateAnnouncementContent value={value} mediaURL={(block) => (block.resourceId ? updateAnnouncementPreviewURL(block.resourceId) : "")} />
</aside>
</div>
</div>
);
}

View File

@@ -1025,6 +1025,23 @@
font-weight: 650;
}
:root:not(.dark) :is(.admin-drawer, .admin-secondary-drawer) .ant-drawer-content,
:root:not(.dark) :is(.admin-modal-root, .admin-channel-modal-root) .ant-modal-container {
border: 0 !important;
box-shadow: 0 16px 40px rgb(15 23 42 / 0.1) !important;
}
:root:not(.dark) :is(.admin-drawer, .admin-secondary-drawer) :is(.ant-drawer-header, .ant-drawer-footer),
:root:not(.dark) :is(.admin-modal-root, .admin-channel-modal-root) :is(.ant-modal-header, .ant-modal-footer) {
border-color: transparent !important;
background: var(--admin-surface) !important;
}
:root:not(.dark) :is(.admin-drawer, .admin-secondary-drawer) .ant-drawer-body,
:root:not(.dark) :is(.admin-modal-root, .admin-channel-modal-root) .ant-modal-body {
background: var(--admin-surface) !important;
}
@media (prefers-reduced-motion: reduce) {
[data-admin-root] *,
body.admin-overlays * {