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 * {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, useState, type Dispatch, type SetStateA
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { App } from "antd";
|
||||
|
||||
import { applyGenerationTaskResultToNodes, generationTaskCanReloadResource, generationTaskNodeId } from "@/lib/canvas/canvas-generation-task-sync";
|
||||
import { applyGenerationTaskResultToNodes, generationTaskCanReloadResource, generationTaskNodeId, generationTaskOutputsApplied, shouldRecoverCanvasImageOutputs } from "@/lib/canvas/canvas-generation-task-sync";
|
||||
import { commitCanvasGenerationResult } from "@/lib/canvas/canvas-generation-result";
|
||||
import { reconcileImageBatchRoot } from "@/lib/canvas/canvas-image-batch-retry";
|
||||
import { markCanvasTaskRecoveryUnconfirmed } from "@/lib/canvas/canvas-task-state";
|
||||
@@ -13,8 +13,10 @@ import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
|
||||
import { useCanvasStore } from "@/stores/canvas/use-canvas-store";
|
||||
import { cinematicStoryboardColumns, storyboardRowsFromTask } from "@/lib/canvas/canvas-project-domain";
|
||||
import { generationTaskMetadata } from "@/lib/canvas/canvas-project-generation";
|
||||
import { nodeGenerationPrompt } from "@/lib/canvas/generation-contract";
|
||||
import { generationFailureMetadata } from "@/lib/generation-error";
|
||||
import { runGenerationConsumer } from "@/services/generation-consumer-lifecycle";
|
||||
import { attachNodeEffectKey } from "@/services/generation-task-materializer";
|
||||
import { consumeCanvasGenerationContinuation } from "./use-canvas-operation-history";
|
||||
|
||||
type CanvasGenerationRequest = {
|
||||
@@ -172,7 +174,7 @@ export async function recoverCanvasGenerationTaskNode(input: {
|
||||
} catch (error) {
|
||||
if (!isCurrentProject() || (error instanceof Error && error.name === "AbortError")) return;
|
||||
if (isCanvasGenerationDurableAckError(error)) return;
|
||||
const failure = generationFailureMetadata(error, input.node.metadata?.composerContent || input.node.metadata?.prompt || "");
|
||||
const failure = generationFailureMetadata(error, nodeGenerationPrompt(input.node));
|
||||
input.setNodes((current) =>
|
||||
current.map((item) =>
|
||||
item.id === input.node.id
|
||||
@@ -233,7 +235,7 @@ export function useCanvasGeneration({ projectId, domainProjectId, projectLoaded,
|
||||
status: (node.metadata?.taskStatus as GenerationTask["status"]) || "running",
|
||||
stage: node.metadata?.taskStage,
|
||||
progress: node.metadata?.taskProgress,
|
||||
prompt: node.metadata?.prompt || "",
|
||||
prompt: nodeGenerationPrompt(node),
|
||||
attempts: 1,
|
||||
createdAt: node.metadata?.taskCreatedAt || new Date().toISOString(),
|
||||
updatedAt: node.metadata?.taskUpdatedAt || new Date().toISOString(),
|
||||
@@ -258,7 +260,7 @@ export function useCanvasGeneration({ projectId, domainProjectId, projectLoaded,
|
||||
if (node.id !== targetNodeId) return node;
|
||||
const failed = task.status === "failed" || task.status === "cancelled";
|
||||
const hasCompletedContent = task.status === "succeeded" && Boolean(node.metadata?.content || node.metadata?.storageKey);
|
||||
const failure = failed ? generationFailureMetadata(task.error || (task.status === "cancelled" ? "任务已取消" : "任务失败"), node.metadata?.composerContent || node.metadata?.prompt || task.prompt || "") : undefined;
|
||||
const failure = failed ? generationFailureMetadata(task.error || (task.status === "cancelled" ? "任务已取消" : "任务失败"), nodeGenerationPrompt(node) || task.prompt || "") : undefined;
|
||||
return {
|
||||
...node,
|
||||
metadata: {
|
||||
@@ -293,14 +295,14 @@ export function useCanvasGeneration({ projectId, domainProjectId, projectLoaded,
|
||||
const applied = await applyGenerationTaskResultToNodes(nodesRef.current, task, nodeId);
|
||||
if (signal.aborted) throw new DOMException("The operation was aborted", "AbortError");
|
||||
if (!applied.updated || !applied.node) throw new Error("画布中找不到对应任务节点");
|
||||
setNodes((current) => commitCanvasGenerationResult(current, before, applied.node!, task.id));
|
||||
setNodes((current) => commitCanvasGenerationResult(current, before, applied.node!, task.id, applied.additionalNodes));
|
||||
};
|
||||
if (!task.outputs?.length && task.type === "canvas_text") {
|
||||
await applyStoredTaskResult();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await consumeGenerationTaskNode(
|
||||
const materialized = await consumeGenerationTaskNode(
|
||||
task,
|
||||
nodeId,
|
||||
0,
|
||||
@@ -319,10 +321,12 @@ export function useCanvasGeneration({ projectId, domainProjectId, projectLoaded,
|
||||
{ signal: consumerControllerRef.current.signal },
|
||||
);
|
||||
const currentNode = nodesRef.current.find((node) => node.id === nodeId);
|
||||
if (task.status === "succeeded" && (!currentNode?.metadata?.content || currentNode.metadata.status !== NODE_STATUS_SUCCESS)) {
|
||||
if (task.status === "succeeded" && (!currentNode || !generationTaskOutputsApplied(currentNode, materialized))) {
|
||||
// attach effect 可能已经完成,但旧画布快照仍停留在 loading。
|
||||
// 最终以节点是否真实拿到媒体结果为准,不能只信幂等记录。
|
||||
await applyStoredTaskResult();
|
||||
const output = materialized.outputs?.find((item) => item.outputIndex === 0);
|
||||
if (!output) throw new Error("生成任务没有可恢复的输出");
|
||||
await applyCanvasGenerationTaskNodeEffect({ projectId, nodeId, task: materialized, output, effectKey: attachNodeEffectKey(task.id, nodeId, 0), signal: consumerControllerRef.current.signal, nodesRef, setNodes });
|
||||
}
|
||||
} catch (error) {
|
||||
// 成功任务的副作用确认失败时,直接用已持久化结果回写节点,避免永久停留在生成中。
|
||||
@@ -379,7 +383,7 @@ export function useCanvasGeneration({ projectId, domainProjectId, projectLoaded,
|
||||
const pendingAgentContinuation = node.metadata?.agentGenerationContinuation?.status === "pending";
|
||||
const aggregateBatchRoot = node.metadata?.isBatchRoot && node.metadata.batchChildIds?.length;
|
||||
if (aggregateBatchRoot && !pendingAgentContinuation) return false;
|
||||
return pendingAgentContinuation || node.metadata?.status === NODE_STATUS_LOADING || node.metadata?.errorDetails === "页面刷新后生成已中断,请重新生成。" || Boolean(node.metadata?.taskId && node.metadata.status !== NODE_STATUS_SUCCESS);
|
||||
return shouldRecoverCanvasImageOutputs(node) || pendingAgentContinuation || node.metadata?.status === NODE_STATUS_LOADING || node.metadata?.errorDetails === "页面刷新后生成已中断,请重新生成。" || Boolean(node.metadata?.taskId && node.metadata.status !== NODE_STATUS_SUCCESS);
|
||||
});
|
||||
const needsDiscovery = recoveryNodes.some((node) => !node.metadata?.taskId && !node.metadata?.agentGenerationContinuation?.taskId);
|
||||
let projectTasks: GenerationTask[] = [];
|
||||
@@ -430,7 +434,7 @@ export function useCanvasGeneration({ projectId, domainProjectId, projectLoaded,
|
||||
});
|
||||
} catch (error) {
|
||||
if (!isCurrentProject() || (error instanceof Error && error.name === "AbortError")) return;
|
||||
const failure = generationFailureMetadata(error, node.metadata?.composerContent || node.metadata?.prompt || "");
|
||||
const failure = generationFailureMetadata(error, nodeGenerationPrompt(node));
|
||||
setNodes((current) =>
|
||||
isCurrentProject()
|
||||
? current.map((item) =>
|
||||
|
||||
@@ -706,27 +706,26 @@ export default function CreatePage() {
|
||||
}));
|
||||
if (requestLifecycle.signal.aborted) throw new DOMException("Aborted", "AbortError");
|
||||
const boundTaskIdList = Array.from(boundTaskIds);
|
||||
const generatedImages = settled.flatMap((entry, batchIndex) => {
|
||||
const generatedTasks = settled.flatMap((entry, batchIndex) => {
|
||||
if (entry.status !== "fulfilled") return [];
|
||||
return (entry.value.images || []).map((image, resultIndex) => ({
|
||||
image,
|
||||
return [{
|
||||
result: entry.value,
|
||||
taskId: boundTaskIdsByBatchIndex.get(batchIndex) || boundTaskIdList[batchIndex],
|
||||
batchIndex,
|
||||
resultIndex,
|
||||
}));
|
||||
}];
|
||||
});
|
||||
const taskFailures = settled.filter((entry): entry is PromiseRejectedResult => entry.status === "rejected");
|
||||
const storedImages = await Promise.allSettled(generatedImages.map(async ({ image, taskId, batchIndex }) => {
|
||||
const storedImages = await Promise.allSettled(generatedTasks.map(async ({ result, taskId, batchIndex }) => {
|
||||
if (!taskId) throw new Error("生成任务缺少稳定任务标识");
|
||||
const task = completedCreationGenerationTask(runtime, { taskId, task: boundTasks.get(taskId), mode: "image", prompt: expandedPrompt, result: { mode: "image", images: [image] }, conversationId: activeConversation.id, messageId: assistantMessage.id, batchIndex, batchCount: taskCount });
|
||||
const task = completedCreationGenerationTask(runtime, { taskId, task: boundTasks.get(taskId), mode: "image", prompt: expandedPrompt, result, conversationId: activeConversation.id, messageId: assistantMessage.id, batchIndex, batchCount: taskCount });
|
||||
const materialized = await runtime.consumeGenerationTaskMessage(task, assistantMessage.id, async ({ resultUrls, resultStorageKeys, effectKey }) => {
|
||||
await updateOriginAssistant((item) => runtime.applyGenerationConsumerEffect(item, effectKey, (current) => ({ ...current, status: "done" as const, content: "图片已生成", ...(resultUrls.length ? { resultUrls: Array.from(new Set([...(current.resultUrls || []), ...resultUrls])) } : {}), ...(resultStorageKeys.length ? { resultStorageKeys: Array.from(new Set([...(current.resultStorageKeys || []), ...resultStorageKeys])) } : {}) })).value);
|
||||
}, { signal: requestLifecycle.signal });
|
||||
const url = runtime.generationTaskMaterializedUrls(materialized)[0];
|
||||
if (!url) throw new Error("图片结果资源不可用");
|
||||
return url;
|
||||
const urls = runtime.generationTaskMaterializedUrls(materialized);
|
||||
if (!urls.length) throw new Error("图片结果资源不可用");
|
||||
return urls;
|
||||
}));
|
||||
const resultUrls = storedImages.flatMap((entry) => entry.status === "fulfilled" ? [entry.value] : []);
|
||||
const resultUrls = storedImages.flatMap((entry) => entry.status === "fulfilled" ? entry.value : []);
|
||||
const resourceFailures = storedImages.filter((entry) => entry.status === "rejected");
|
||||
const failedCount = taskFailures.length + resourceFailures.length;
|
||||
if (!resultUrls.length) {
|
||||
|
||||
@@ -181,6 +181,7 @@ export function ChannelModelSettings({ channel, onChange }: { channel: ModelChan
|
||||
model={activeModel}
|
||||
value={activeModelCost?.capabilityConfig || defaultModelCapabilityConfig(activeProtocol, activeModel)}
|
||||
protocol={activeProtocol}
|
||||
workflows={modelProtocolDefinition(activeProtocol, availableProtocols)?.workflows}
|
||||
onChange={(capabilityConfig) => updateCost(activeModel, { capabilityConfig })}
|
||||
/>
|
||||
) : <p className="text-xs text-foreground/50">当前模型类型无需额外配置引用与参数。</p>}
|
||||
|
||||
Reference in New Issue
Block a user