From 1aa539b59318634e8351f60e74a504fc852729d8 Mon Sep 17 00:00:00 2001 From: Rou <76741805+Rou325@users.noreply.github.com> Date: Wed, 9 Sep 2026 14:22:21 +0800 Subject: [PATCH 1/4] =?UTF-8?q?fix(canvas):=20=E5=B1=80=E9=83=A8=E9=87=8D?= =?UTF-8?q?=E7=BB=98=E3=80=81=E5=9B=BE=E7=89=87=E8=A7=84=E6=A0=BC=E4=B8=8E?= =?UTF-8?q?=E4=BB=BB=E5=8A=A1=E5=8F=82=E6=95=B0=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/service/model_capability.go | 12 ++++++++-- backend/internal/service/model_router.go | 7 ++++++ backend/internal/service/model_router_test.go | 13 +++++++++++ .../canvas-drawing-excalidraw-editor.tsx | 20 ++++++++-------- .../canvas/canvas-node-mask-edit-dialog.tsx | 20 ++++------------ web/src/components/image-settings-panel.tsx | 8 ++++--- .../components/image-size-presets-editor.tsx | 12 ++++++---- web/src/lib/image-size-presets.ts | 4 +++- web/src/lib/model-capabilities.ts | 9 +++++++- .../canvas/canvas-project-media-dialogs.tsx | 2 +- .../pages/canvas/use-canvas-media-tools.ts | 23 +++++++++++++++++-- web/src/services/api/generation-task.ts | 21 ++++++++++++++--- web/test/image-size-presets.test.tsx | 18 +++++++++++++-- 13 files changed, 124 insertions(+), 45 deletions(-) diff --git a/backend/internal/service/model_capability.go b/backend/internal/service/model_capability.go index 8c8457876..e32347697 100644 --- a/backend/internal/service/model_capability.go +++ b/backend/internal/service/model_capability.go @@ -822,10 +822,11 @@ func validateImageTask(profile *ImageCapabilityConfig, input canvasGenerationInp return BadAuthRequest(err.Error()) } } - if profile.Quality.Supported && strings.TrimSpace(input.Config.Quality) != "" && !containsCapabilityString(profile.Quality.Values, input.Config.Quality) { + quality := strings.TrimSpace(input.Config.Quality) + if profile.Quality.Supported && quality != "" && !strings.EqualFold(quality, "auto") && !strings.EqualFold(quality, "any") && !containsCapabilityString(profile.Quality.Values, quality) { return BadAuthRequest("图片质量不在当前模型支持范围内") } - if err := validateImagePresetSelection(profile, firstNonEmpty(input.Config.Quality, profile.Quality.Default), firstNonEmpty(input.Config.Size, profile.Size.Default)); err != nil { + if err := validateImagePresetSelection(profile, qualityForImageValidation(quality, profile.Quality.Default), firstNonEmpty(input.Config.Size, profile.Size.Default)); err != nil { return err } count, err := strconv.Atoi(strings.TrimSpace(input.Config.Count)) @@ -835,6 +836,13 @@ func validateImageTask(profile *ImageCapabilityConfig, input canvasGenerationInp return nil } +func qualityForImageValidation(quality string, fallback string) string { + if strings.EqualFold(quality, "auto") || strings.EqualFold(quality, "any") { + return fallback + } + return firstNonEmpty(quality, fallback) +} + func validateImagePresetSelection(profile *ImageCapabilityConfig, quality, ratio string) error { if profile.Size.Parameter != "aspect_ratio" || profile.Size.AllowCustom || len(profile.Size.Presets) == 0 || ratio == "auto" { return nil diff --git a/backend/internal/service/model_router.go b/backend/internal/service/model_router.go index f37e82b2a..49fdc52f3 100644 --- a/backend/internal/service/model_router.go +++ b/backend/internal/service/model_router.go @@ -65,6 +65,13 @@ func ModelRequestIntentFromTaskInput(input map[string]any, taskType string, oper explicitOptions = true for key, value := range options { name := canonicalCapabilityOptionName(key) + // auto/any 表示调用方不指定质量;不能把它当作模型必须声明的 + // 枚举值,否则未列出 auto 的模型会被错误判定为参数不支持。 + if name == "quality" { + if normalized, ok := value.(string); ok && (strings.EqualFold(strings.TrimSpace(normalized), "auto") || strings.EqualFold(strings.TrimSpace(normalized), "any")) { + continue + } + } intent.Options[name] = normalizeModelRequestOption(name, value) } } diff --git a/backend/internal/service/model_router_test.go b/backend/internal/service/model_router_test.go index d2efcddde..4cea97089 100644 --- a/backend/internal/service/model_router_test.go +++ b/backend/internal/service/model_router_test.go @@ -38,6 +38,19 @@ func TestImagePriceTiersMatchResolutionAndActualReferences(t *testing.T) { } } +func TestAutoQualityIsOmittedFromCapabilityIntent(t *testing.T) { + intent := ModelRequestIntentFromTaskInput(map[string]any{ + "mode": "image", + "capabilityOptions": map[string]any{"quality": "auto", "size": "2048x878"}, + }, "canvas_image", "image") + if _, exists := intent.Options["quality"]; exists { + t.Fatalf("quality option = %#v, want omitted for auto", intent.Options["quality"]) + } + if intent.Options["size"] != "2048x878" { + t.Fatalf("size option = %#v, want preserved", intent.Options["size"]) + } +} + func TestModelRequestIntentNormalizesVideoResolution(t *testing.T) { input := map[string]any{ "mode": "video", diff --git a/web/src/components/canvas/canvas-drawing-excalidraw-editor.tsx b/web/src/components/canvas/canvas-drawing-excalidraw-editor.tsx index 8c362468f..cb60da19f 100644 --- a/web/src/components/canvas/canvas-drawing-excalidraw-editor.tsx +++ b/web/src/components/canvas/canvas-drawing-excalidraw-editor.tsx @@ -49,16 +49,16 @@ export const CanvasDrawingExcalidrawEditor = forwardRef { - apiRef.current = api; - api.setActiveTool({ type: "freedraw" }); - onReady(); - }} - UIOptions={{ canvasActions: { loadScene: false, saveToActiveFile: false } }} + initialData={initialData} + langCode="zh-CN" + theme={colorScheme} + autoFocus + excalidrawAPI={(api) => { + apiRef.current = api; + api.setActiveTool({ type: "freedraw" }); + onReady(); + }} + UIOptions={{ canvasActions: { loadScene: false, saveToActiveFile: false } }} /> ); }); diff --git a/web/src/components/canvas/canvas-node-mask-edit-dialog.tsx b/web/src/components/canvas/canvas-node-mask-edit-dialog.tsx index 10cd82797..915f35c9b 100644 --- a/web/src/components/canvas/canvas-node-mask-edit-dialog.tsx +++ b/web/src/components/canvas/canvas-node-mask-edit-dialog.tsx @@ -41,10 +41,10 @@ export function CanvasNodeMaskEditDialog({ dataUrl, open, config, onClose, onCon setBrushSize(defaultBrushSize); setMode("paint"); setError(""); - setAdvancedOpen(false); + setAdvancedOpen(true); setGenerationConfig(config); void readImageMeta(dataUrl).then(setImage); - }, [config, dataUrl, open]); + }, [dataUrl, open]); useEffect(() => { clearCanvas(maskCanvasRef.current); @@ -115,7 +115,7 @@ export function CanvasNodeMaskEditDialog({ dataUrl, open, config, onClose, onCon
- + {image ? ( <> @@ -123,7 +123,7 @@ export function CanvasNodeMaskEditDialog({ dataUrl, open, config, onClose, onCon ref={previewCanvasRef} width={image.width} height={image.height} - className="absolute inset-0 h-full w-full cursor-crosshair touch-none" + className="absolute inset-0 z-10 h-full w-full cursor-crosshair touch-none" onPointerDown={startDraw} onPointerMove={moveDraw} onPointerUp={stopDraw} @@ -173,17 +173,6 @@ export function CanvasNodeMaskEditDialog({ dataUrl, open, config, onClose, onCon {error ?
{error}
: null}
- setGenerationConfig((current) => ({ ...current, [key]: value }))} - /> -
- + toggleTier(tier, ratios, enabled)} />
diff --git a/web/src/lib/image-size-presets.ts b/web/src/lib/image-size-presets.ts index cfc1bfcfb..f7c951e30 100644 --- a/web/src/lib/image-size-presets.ts +++ b/web/src/lib/image-size-presets.ts @@ -2,7 +2,7 @@ import { buildImageResolutionOptions, type ImageResolutionOption, type ImageReso import type { ImageCapabilityConfig } from "./model-capabilities"; export const IMAGE_RESOLUTIONS: ImageResolutionTier[] = ["1k", "2k", "4k"]; -export const IMAGE_RATIOS = ["1:1", "16:9", "9:16", "4:3", "3:4", "3:2", "2:3", "21:9"]; +export const IMAGE_RATIOS = ["1:1", "16:9", "9:16", "4:3", "3:4", "3:2", "2:3", "4:5", "5:4", "21:9"]; const standardSizes: Record = { "1:1": ["1024x1024", "2048x2048", "2880x2880"], "16:9": ["1824x1024", "2752x1536", "3840x2160"], @@ -11,6 +11,8 @@ const standardSizes: Record = { "3:4": ["1024x1360", "1728x2304", "2448x3264"], "3:2": ["1536x1024", "2496x1664", "3504x2336"], "2:3": ["1024x1536", "1664x2496", "2336x3504"], + "4:5": ["1024x1280", "1792x2240", "2560x3200"], + "5:4": ["1280x1024", "2240x1792", "3200x2560"], "21:9": ["2048x878", "3136x1344", "3808x1632"], }; diff --git a/web/src/lib/model-capabilities.ts b/web/src/lib/model-capabilities.ts index 0c4140db6..ca5854ec9 100644 --- a/web/src/lib/model-capabilities.ts +++ b/web/src/lib/model-capabilities.ts @@ -896,7 +896,14 @@ function matchWorkflowValue(value: string, options: string[]) { export function normalizeImageValue(profile: ImageCapabilityConfig, value: { size?: string; quality?: string; count?: string; transparentBackground?: string }) { const size = normalizeImageSizeSetting(profile, value.size); - const quality = profile.quality.supported ? (value.quality && profile.quality.values.includes(value.quality) ? value.quality : profile.quality.default || "auto") : profile.quality.default || "auto"; + const requestedQuality = String(value.quality || "").trim().toLowerCase(); + const quality = profile.quality.supported + ? requestedQuality === "auto" || requestedQuality === "any" + ? "auto" + : value.quality && profile.quality.values.includes(value.quality) + ? value.quality + : profile.quality.default || "auto" + : profile.quality.default || "auto"; const count = String(Math.max(1, Math.min(profile.maxOutputs, Math.floor(Math.abs(Number(value.count)) || 1)))); const transparentBackground = profile.transparentBackground.supported && value.transparentBackground === "true" ? "true" : "false"; return { size, quality, count, transparentBackground }; diff --git a/web/src/pages/canvas/canvas-project-media-dialogs.tsx b/web/src/pages/canvas/canvas-project-media-dialogs.tsx index 14f1b5877..2be17fde8 100644 --- a/web/src/pages/canvas/canvas-project-media-dialogs.tsx +++ b/web/src/pages/canvas/canvas-project-media-dialogs.tsx @@ -47,7 +47,7 @@ export function CanvasProjectMediaDialogs({ <> {cropNode?.metadata?.content ? onCrop(cropNode, crop)} /> : null} {annotationNode?.metadata?.content ? onAnnotate(annotationNode, dataUrl)} /> : null} - {maskEditNode?.metadata?.content ? onMaskEdit(maskEditNode, payload)} /> : null} + {maskEditNode?.metadata?.content ? onMaskEdit(maskEditNode, payload)} /> : null} {splitNode?.metadata?.content ? onSplit(splitNode, params)} /> : null} {upscaleNode?.metadata?.content ? onUpscale(upscaleNode, params)} /> : null} diff --git a/web/src/pages/canvas/use-canvas-media-tools.ts b/web/src/pages/canvas/use-canvas-media-tools.ts index 326041ea2..3e07aacf4 100644 --- a/web/src/pages/canvas/use-canvas-media-tools.ts +++ b/web/src/pages/canvas/use-canvas-media-tools.ts @@ -36,12 +36,22 @@ import { mergeVideos, type MergeVideoProgress } from "@/lib/canvas/canvas-video- import { extractVideoAudio, trimVideoSegment } from "@/lib/canvas/canvas-video-segment"; import { generationErrorMessage } from "@/lib/generation-error"; import { modelCapabilityConfigFor } from "@/lib/model-capabilities"; +import { defaultImageParamsForModel } from "@/lib/model-selection"; import { navigateToSettings } from "@/lib/settings-navigation"; import { storeGeneratedVideo } from "@/services/api/video"; import { getMediaBlob, uploadMediaFile } from "@/services/file-storage"; import { uploadImage } from "@/services/image-storage"; import { ensureCanvasNodeAsset } from "@/services/project-asset-sync"; import type { GenerationTask } from "@/services/api/task-center"; + +function normalizeMaskEditQuality(quality: string | undefined, size: string | undefined) { + const value = String(quality || "").trim().toLowerCase(); + if (value && value !== "auto" && value !== "any") return value; + const match = String(size || "").trim().toLowerCase().match(/^(\d+)x(\d+)$/); + if (!match) return quality || "auto"; + const pixels = Number(match[1]) * Number(match[2]); + return pixels <= 2_000_000 ? "1k" : pixels <= 4_300_000 ? "2k" : pixels <= 8_294_400 ? "4k" : quality || "auto"; +} import { defaultConfig, resolveModelRequestConfig, useConfigStore, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store"; import { CanvasNodeType, type CanvasConnection, type CanvasNodeData, type ContextMenuState } from "@/types/canvas"; import type { StartCanvasUploadStatus } from "./use-canvas-upload"; @@ -576,13 +586,22 @@ export function useCanvasMediaTools({ const maskEditImageNode = useCallback(async (node: CanvasNodeData, payload: CanvasImageMaskEditPayload) => { if (!node.metadata?.content) return; const baseGenerationConfig = buildGenerationConfig(effectiveConfig, node, "image"); + const selectedModel = payload.generationConfig?.model || payload.generationConfig?.imageModel || baseGenerationConfig.model; + const modelDefaults = defaultImageParamsForModel(baseGenerationConfig, selectedModel); + const selectedImageProfile = modelCapabilityConfigFor(baseGenerationConfig, selectedModel).image; + if (!selectedImageProfile?.references.maskSupported) { + message.error("当前图片模型不支持局部重绘蒙版,请选择支持蒙版编辑的模型"); + return; + } const generationConfig = { ...baseGenerationConfig, ...payload.generationConfig, - model: payload.generationConfig?.model || payload.generationConfig?.imageModel || baseGenerationConfig.model, + model: selectedModel, imageModel: payload.generationConfig?.imageModel || payload.generationConfig?.model || effectiveConfig.imageModel, + quality: normalizeMaskEditQuality(payload.generationConfig?.quality || node.metadata?.quality || baseGenerationConfig.quality || modelDefaults.quality, payload.generationConfig?.size || node.metadata?.size || baseGenerationConfig.size || modelDefaults.size), count: String(payload.generationConfig?.count || 1), - size: payload.generationConfig?.size || node.metadata?.size || "auto", + // 原图像素尺寸不是模型的输出尺寸合同;非高级设置时使用模型默认尺寸,避免把节点尺寸误发给上游。 + size: payload.generationConfig?.size || node.metadata?.size || modelDefaults.size, }; if (!isAiConfigReady(generationConfig, generationConfig.model)) { navigateToSettings({ continueCreation: true }); diff --git a/web/src/services/api/generation-task.ts b/web/src/services/api/generation-task.ts index 688ae6b51..5d81896c3 100644 --- a/web/src/services/api/generation-task.ts +++ b/web/src/services/api/generation-task.ts @@ -538,7 +538,7 @@ export function backendProviderConfig(config: AiConfig, mode: BackendGenerationM if (workflow) return workflowProviderConfig(config, requestConfig, workflow); const generationOptions = { size: config.size, - quality: config.quality, + quality: omittedImageQuality(config.quality), transparentBackground: config.transparentBackground, count: config.count, videoSeconds: config.videoSeconds, @@ -623,13 +623,28 @@ function logicalCapabilityOptions(config: AiConfig, mode: BackendGenerationMode) const channel = resolveModelChannel(config, config.model); const spec = channel.modelCosts?.find((item) => item.model === modelOptionName(config.model))?.logicalCapabilitySpec; const candidates: Record = mode === "image" - ? { size: config.size, quality: config.quality, transparentBackground: config.transparentBackground === "true", count: Number(config.count) } + ? { size: config.size, quality: omittedImageQuality(config.quality), transparentBackground: config.transparentBackground === "true", count: Number(config.count) } : mode === "video" ? { size: config.size, videoSeconds: Number(config.videoSeconds), vquality: config.vquality, videoGenerateAudio: config.videoGenerateAudio === "true", videoWatermark: config.videoWatermark === "true" } : mode === "audio" ? { audioVoice: config.audioVoice, audioFormat: config.audioFormat, audioSpeed: Number(config.audioSpeed) } : {}; - return Object.fromEntries(Object.entries(candidates).filter(([key]) => Boolean(spec?.options?.[key]))); + const filtered = Object.fromEntries(Object.entries(candidates).filter(([key]) => Boolean(spec?.options?.[key]))); + // 图片质量和画幅同时参与按规格计费匹配。即使逻辑模型能力声明只把其中一项 + // 暴露给供应线路,报价仍需要看到客户端最终选择,避免局部重绘等编辑请求落到 + // “未配置所选规格”的错误分支。 + if (mode === "image") { + for (const key of ["quality", "size"] as const) { + const value = candidates[key]; + if (value !== undefined && value !== null && String(value).trim() !== "") filtered[key] = value; + } + } + return filtered; +} + +function omittedImageQuality(value: string | undefined) { + const normalized = String(value || "").trim().toLowerCase(); + return normalized === "auto" || normalized === "any" ? undefined : value; } export function parseBackendGenerationResult(task: GenerationTask): BackendGenerationResult { diff --git a/web/test/image-size-presets.test.tsx b/web/test/image-size-presets.test.tsx index 1c707980d..dd3a188d0 100644 --- a/web/test/image-size-presets.test.tsx +++ b/web/test/image-size-presets.test.tsx @@ -42,6 +42,17 @@ describe("统一图片分辨率与宽高比", () => { } }); + test("固定比例编辑器包含 4:5 和 5:4 三档尺寸", () => { + expect(IMAGE_RATIOS).toContain("4:5"); + expect(IMAGE_RATIOS).toContain("5:4"); + expect(imagePresetForRatio("1k", "4:5").size).toBe("1024x1280"); + expect(imagePresetForRatio("2k", "4:5").size).toBe("1792x2240"); + expect(imagePresetForRatio("4k", "4:5").size).toBe("2560x3200"); + expect(imagePresetForRatio("1k", "5:4").size).toBe("1280x1024"); + expect(imagePresetForRatio("2k", "5:4").size).toBe("2240x1792"); + expect(imagePresetForRatio("4k", "5:4").size).toBe("3200x2560"); + }); + test("管理员设置同步支持值、默认值,并保持精确尺寸", () => { const profile = defaultImageCapabilityConfig(); profile.size = { parameter: "size", values: ["1920x1080"], default: "1920x1080", allowCustom: false }; @@ -139,8 +150,11 @@ describe("统一图片分辨率与宽高比", () => { expect(editor).toContain(`aria-label="${tier} 16:9"`); } expect(editor).toContain('aria-label="默认输出"'); - expect(editor).toContain("全不选"); - expect(editor).toContain("全选"); + expect(editor).not.toContain("全不选"); + expect(editor).not.toContain("全选"); + expect(editor).toContain('aria-label="启用 1K 规格"'); + expect(editor).toContain('aria-label="启用 2K 规格"'); + expect(editor).toContain('aria-label="启用 4K 规格"'); const readOnly = renderToStaticMarkup( {}} />); const buttons = [...readOnly.matchAll(/]*>/g)]; expect(buttons.length).toBeGreaterThan(24); From 86330bd56b05c76e15f4ceadc97ddefbe906ab2f Mon Sep 17 00:00:00 2001 From: Rou <76741805+Rou325@users.noreply.github.com> Date: Wed, 9 Sep 2026 17:45:06 +0800 Subject: [PATCH 2/4] fix(plugin): add AutoDL ZM reference video workflows --- plugin-packages/autodl-comfyui.yingce-plugin | Bin 10365 -> 10474 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/plugin-packages/autodl-comfyui.yingce-plugin b/plugin-packages/autodl-comfyui.yingce-plugin index 5adb0bce3b30870f80ddcb65e1d5e7020725b68f..28379b8396e2e5dcd0493570a62894dd81b126c1 100644 GIT binary patch literal 10474 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 literal 10365 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 From c7370b1d0fe2fd88dbe1e4f16347a225ea2bf465 Mon Sep 17 00:00:00 2001 From: Rou <76741805+Rou325@users.noreply.github.com> Date: Wed, 9 Sep 2026 21:31:53 +0800 Subject: [PATCH 3/4] =?UTF-8?q?fix(announcement):=20=E7=99=BB=E5=BD=95?= =?UTF-8?q?=E5=90=8E=E5=A7=8B=E7=BB=88=E6=8F=90=E7=A4=BA=E5=BD=93=E5=89=8D?= =?UTF-8?q?=E5=85=AC=E5=91=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../layout/system-announcement-center.tsx | 35 +++++++++++++++---- 1 file changed, 28 insertions(+), 7 deletions(-) diff --git a/web/src/components/layout/system-announcement-center.tsx b/web/src/components/layout/system-announcement-center.tsx index d8ac370c9..0ff6c12cd 100644 --- a/web/src/components/layout/system-announcement-center.tsx +++ b/web/src/components/layout/system-announcement-center.tsx @@ -12,6 +12,7 @@ const AnnouncementTimelineModal = lazy(() => import("@/components/ui/aceternity/ const ANNOUNCEMENT_REFRESH_INTERVAL_MS = 5 * 60_000; const ANNOUNCEMENT_CACHE_TTL_MS = 60_000; const ANNOUNCEMENT_DISMISS_TODAY_PREFIX = "yingce.announcements.dismiss-today"; +const ANNOUNCEMENT_DISMISS_SESSION_PREFIX = "yingce.announcements.dismiss-session"; type AnnouncementFeed = Awaited>; @@ -46,9 +47,9 @@ export function SystemAnnouncementCenter({ userId, className, style, showLabel = const error = feedQuery.error instanceof Error ? feedQuery.error.message : feedQuery.error ? "读取公告失败" : ""; useEffect(() => { - if (!autoOpen || open || !feedQuery.isSuccess || unreadCount <= 0 || announcements.length === 0) return; + if (!autoOpen || open || !feedQuery.isSuccess || announcements.length === 0) return; const fingerprint = announcementFeedFingerprint(announcements); - if (!fingerprint || fingerprint === dismissedFingerprint || announcementAutoPromptSuppressed(userId)) return; + if (!fingerprint || fingerprint === dismissedFingerprint || announcementAutoPromptSuppressed(userId, fingerprint)) return; setAutomaticPrompt(true); setOpen(true); }, [announcements, autoOpen, dismissedFingerprint, feedQuery.isSuccess, open, unreadCount, userId]); @@ -71,7 +72,8 @@ export function SystemAnnouncementCenter({ userId, className, style, showLabel = const dismissAutomaticPrompt = (duration: "once" | "today") => { const fingerprint = announcementFeedFingerprint(announcements); if (fingerprint) setDismissedFingerprint(fingerprint); - if (duration === "today") rememberAnnouncementDismissalToday(userId); + if (fingerprint) rememberAnnouncementDismissalSession(userId, fingerprint); + if (duration === "today") rememberAnnouncementDismissalToday(userId, fingerprint); setAutomaticPrompt(false); setOpen(false); }; @@ -138,17 +140,36 @@ function announcementFeedFingerprint(announcements: AnnouncementFeed["announceme .join("|"); } -function announcementAutoPromptSuppressed(userId: string) { +function announcementAutoPromptSuppressed(userId: string, fingerprint: string) { + if (hasAnnouncementDismissalSession(userId, fingerprint)) return true; try { - return localStorage.getItem(`${ANNOUNCEMENT_DISMISS_TODAY_PREFIX}.${userId}`) === localDateKey(); + const value = localStorage.getItem(`${ANNOUNCEMENT_DISMISS_TODAY_PREFIX}.${userId}`); + const parsed = value ? JSON.parse(value) as { date?: string; fingerprint?: string } : null; + return parsed?.date === localDateKey() && parsed.fingerprint === fingerprint; } catch { return false; } } -function rememberAnnouncementDismissalToday(userId: string) { +function hasAnnouncementDismissalSession(userId: string, fingerprint: string) { try { - localStorage.setItem(`${ANNOUNCEMENT_DISMISS_TODAY_PREFIX}.${userId}`, localDateKey()); + return sessionStorage.getItem(`${ANNOUNCEMENT_DISMISS_SESSION_PREFIX}.${userId}`) === fingerprint; + } catch { + return false; + } +} + +function rememberAnnouncementDismissalSession(userId: string, fingerprint: string) { + try { + sessionStorage.setItem(`${ANNOUNCEMENT_DISMISS_SESSION_PREFIX}.${userId}`, fingerprint); + } catch { + // sessionStorage 不可用时保留页内 dismissedFingerprint 作为降级。 + } +} + +function rememberAnnouncementDismissalToday(userId: string, fingerprint: string) { + try { + localStorage.setItem(`${ANNOUNCEMENT_DISMISS_TODAY_PREFIX}.${userId}`, JSON.stringify({ date: localDateKey(), fingerprint })); } catch { // 浏览器禁用存储时仍允许关闭弹窗,本次组件生命周期内不会重复打开。 } From 81162f91387764bf6848f0d6e8ccc80694b25faa Mon Sep 17 00:00:00 2001 From: Rou <76741805+Rou325@users.noreply.github.com> Date: Wed, 9 Sep 2026 21:56:43 +0800 Subject: [PATCH 4/4] =?UTF-8?q?fix(admin):=20=E6=A8=A1=E5=9E=8B=E5=AF=BC?= =?UTF-8?q?=E5=85=A5=20-=20=E6=8C=89=E4=B8=8A=E6=B8=B8=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=20ID=20=E5=8E=BB=E9=87=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/service/channel_models.go | 5 +++-- web/src/pages/admin/components/channel-model-manager.tsx | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/backend/internal/service/channel_models.go b/backend/internal/service/channel_models.go index 02cdc4a1b..37c83d80f 100644 --- a/backend/internal/service/channel_models.go +++ b/backend/internal/service/channel_models.go @@ -178,7 +178,7 @@ func (s *Service) FetchAdminChannelModels(ctx context.Context, actor *model.User if idErr != nil { return nil, idErr } - missing = append(missing, model.ChannelModel{ID: modelID, ChannelID: channelID, ModelKey: name, DisplayName: name, BillingMode: "fixed_request", Enabled: false, PriceVersion: 1}) + missing = append(missing, model.ChannelModel{ID: modelID, ChannelID: channelID, ModelKey: name, ProviderModelKey: name, DisplayName: name, BillingMode: "fixed_request", Enabled: false, PriceVersion: 1}) } added, err := s.repo.CreateMissingChannelModels(missing) if err != nil { @@ -249,7 +249,8 @@ func (s *Service) ImportAdminChannelModels(ctx context.Context, actor *model.Use } known := make(map[string]struct{}, len(existing)) for _, item := range existing { - known[channelModelCatalogKey(item.ModelKey)] = struct{}{} + providerKey := firstNonEmpty(item.ProviderModelKey, item.ModelKey) + known[channelModelCatalogKey(providerKey)] = struct{}{} } retired := retiredChannelModelKeys(channel.RetiredModelsJSON) missing := make([]model.ChannelModel, 0, len(chosen)) diff --git a/web/src/pages/admin/components/channel-model-manager.tsx b/web/src/pages/admin/components/channel-model-manager.tsx index e78d71e32..1b7b57e39 100644 --- a/web/src/pages/admin/components/channel-model-manager.tsx +++ b/web/src/pages/admin/components/channel-model-manager.tsx @@ -231,7 +231,7 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel: return true; }); const pagedItems = filteredItems.slice((page - 1) * pageSize, page * pageSize); - const existingFetchModelKeys = new Set(items.map((item) => normalizeFetchModelKey(item.modelKey))); + const existingFetchModelKeys = new Set(items.flatMap((item) => [item.modelKey, item.providerModelKey].filter(Boolean).map(normalizeFetchModelKey))); const selectedNewFetchModels = selectedFetchModels.filter((name) => !existingFetchModelKeys.has(normalizeFetchModelKey(name))); const selectedExistingFetchCount = selectedFetchModels.length - selectedNewFetchModels.length; const allFetchModelsSelected = fetchPreviewModels.length > 0 && fetchPreviewModels.every((name) => selectedFetchModels.includes(name));