feat(*): 创作配置 - 支持 Midjourney、多图保存与自定义公告
Co-authored-by: ddcat666 <26564566+ddcat666@users.noreply.github.com>
This commit is contained in:
@@ -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" />} />}
|
||||
|
||||
@@ -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("请选择当前能力下已启用的请求协议");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}>
|
||||
{(
|
||||
[
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 * {
|
||||
|
||||
Reference in New Issue
Block a user