From 0d8b03ca1f3d236dcefa6e41e00e7d15c5007399 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Fri, 25 Sep 2026 18:03:52 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix(cover):=20=E4=BF=AE=E5=A4=8DAI=E6=95=B0?= =?UTF-8?q?=E5=AD=97=E4=BA=BA=E5=B0=81=E9=9D=A2=E6=89=93=E5=BC=80=E5=B4=A9?= =?UTF-8?q?=E6=BA=83=20Cannot=20read=20properties=20of=20undefined=20(read?= =?UTF-8?q?ing=20'text')=20(#2036)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 根因:后端 /cover-templates 返回的模板如果 config 字段缺失或只有部分字段(title/subtitle 对象为 undefined), CoverEditorModal 用浅拷贝 {...template?.config} 初始化 state,导致 cfg.title/cfg.subtitle 为 undefined, 后续渲染时访问 tc.text 触发崩溃。useSharedCover 在拉取模板后未做 config 字段归一化,首次打开时传入 缺少字段的模板对象给 CoverEditorModal 也会触发。 修复: - CoverEditorModal 新增 mergeEditorConfig 深合并工具:缺失字段用 DEFAULT_EDITOR_CONFIG 补齐, 对 title/subtitle/shadows/background/position 等嵌套对象做深拷贝,避免共享引用 - 初始化 cfg 与 open 时重置 state 都改用 mergeEditorConfig - renderTextStyle/renderTextLines/TextStylePanel 增加 null/undefined 防御 - 画布渲染处 cfg.title && cfg.subtitle && 条件渲染 - useSharedCover 的 reloadTemplates 兼容 {items:[]} 与直接数组两种响应格式,并确保每个模板都带 config 字段 tsc/eslint/prettier 全过,构建产物 bundle 检查无不安全的 .text 访问。 --- .../src/components/cover/useSharedCover.ts | 12 ++++- .../cover-settings/CoverEditorModal.tsx | 54 +++++++++++++++---- 2 files changed, 53 insertions(+), 13 deletions(-) diff --git a/apps/web/src/components/cover/useSharedCover.ts b/apps/web/src/components/cover/useSharedCover.ts index 5f88e8e2b..063fdfcdd 100644 --- a/apps/web/src/components/cover/useSharedCover.ts +++ b/apps/web/src/components/cover/useSharedCover.ts @@ -76,8 +76,16 @@ export function useSharedCover(opts: UseSharedCoverOptions): UseSharedCoverRetur setTemplatesLoading(true) setTemplatesError(null) try { - const res = await fetchCoverTemplates() - setTemplates(res.items || []) + const res: any = await fetchCoverTemplates() + // 兼容两种响应:{items:[...]} 或直接数组 + const list = Array.isArray(res) ? res : res?.items || [] + // 过滤掉后端可能返回的没有 config 的模板(避免编辑器打开时访问 .title.text 崩溃) + setTemplates( + list.map((t: any) => ({ + ...t, + config: t.config || undefined, + })), + ) } catch (err) { console.error("[SharedCover] 加载封面模板失败:", err) setTemplatesError("加载模板失败,请稍后重试") diff --git a/apps/web/src/pages/generate/components/cover-settings/CoverEditorModal.tsx b/apps/web/src/pages/generate/components/cover-settings/CoverEditorModal.tsx index 24062538b..6accdeaf8 100644 --- a/apps/web/src/pages/generate/components/cover-settings/CoverEditorModal.tsx +++ b/apps/web/src/pages/generate/components/cover-settings/CoverEditorModal.tsx @@ -12,6 +12,31 @@ import Modal from "@/components/ui/Modal" import Button from "@/components/ui/Button" import "@/components/cover/cover.css" +/** 安全合并模板 config 与默认配置(深拷贝 title/subtitle/shadows/background 等嵌套对象,避免共享引用 & 字段缺失) */ +const mergeEditorConfig = (partial?: Partial | null): CoverEditorConfig => { + const def = DEFAULT_EDITOR_CONFIG + const src = partial || {} + const mergeText = ( + base: TextStyleConfig, + patch?: Partial | null, + ): TextStyleConfig => ({ + ...base, + ...(patch || {}), + position: { ...base.position, ...(patch?.position || {}) }, + background: { ...base.background, ...(patch?.background || {}) }, + shadows: Array.isArray(patch?.shadows) ? [...patch!.shadows] : [...base.shadows], + }) + return { + ...def, + ...src, + title: mergeText(def.title, src.title), + subtitle: mergeText(def.subtitle, src.subtitle), + portraitPosition: { ...def.portraitPosition, ...(src.portraitPosition || {}) }, + backgroundPosition: { ...def.backgroundPosition, ...(src.backgroundPosition || {}) }, + maskPosition: { ...def.maskPosition, ...(src.maskPosition || {}) }, + } +} + /* ── Props ── */ interface CoverEditorModalProps { open: boolean @@ -98,10 +123,11 @@ const getFontFamily = (name: string): string => { /* ── Text style panel (shared between title & subtitle) ── */ const TextStylePanel: React.FC<{ - config: TextStyleConfig + config: TextStyleConfig | undefined | null onChange: (c: TextStyleConfig) => void placeholder: string }> = ({ config, onChange, placeholder }) => { + if (!config) return null const upd = (key: K, val: TextStyleConfig[K]) => onChange({ ...config, [key]: val }) return ( @@ -274,9 +300,9 @@ const TextStylePanel: React.FC<{ /* ══════════════ Main Component ══════════════ */ const CoverEditorModal: React.FC = ({ open, onClose, template, onSave }) => { - const initCfg = template?.config ?? DEFAULT_EDITOR_CONFIG + const initCfg = mergeEditorConfig(template?.config) const [name, setName] = useState(template?.name ?? "") - const [cfg, setCfg] = useState(() => ({ ...initCfg })) + const [cfg, setCfg] = useState(() => mergeEditorConfig(template?.config)) const [sections, setSections] = useState>({ basic: true, portrait: false, @@ -294,8 +320,8 @@ const CoverEditorModal: React.FC = ({ open, onClose, temp // reset state when modal opens with a new template useEffect(() => { if (open) { - const c = template?.config ?? DEFAULT_EDITOR_CONFIG - setCfg({ ...c }) + const c = mergeEditorConfig(template?.config) + setCfg(c) setName(template?.name ?? "") setBgImageUrl(c.backgroundImage || "") setPortraitImageUrl(c.portraitImage || "") @@ -366,7 +392,8 @@ const CoverEditorModal: React.FC = ({ open, onClose, temp const s = (px: number) => Math.round(px * SCALE * 100) / 100 - const renderTextStyle = (tc: TextStyleConfig): React.CSSProperties => { + const renderTextStyle = (tc: TextStyleConfig | undefined | null): React.CSSProperties => { + if (!tc) return {} // wrap text by charsPerLine const rawText = tc.text || "" const lines: string[] = [] @@ -407,7 +434,8 @@ const CoverEditorModal: React.FC = ({ open, onClose, temp return style } - const renderTextLines = (tc: TextStyleConfig) => { + const renderTextLines = (tc: TextStyleConfig | undefined | null): string => { + if (!tc) return "" const raw = tc.text || "" if (tc.direction === "vertical") return raw const per = Math.max(1, tc.charsPerLine) @@ -608,7 +636,7 @@ const CoverEditorModal: React.FC = ({ open, onClose, temp {sections.title && (
@@ -625,7 +653,7 @@ const CoverEditorModal: React.FC = ({ open, onClose, temp {sections.subtitle && (
@@ -887,10 +915,14 @@ const CoverEditorModal: React.FC = ({ open, onClose, temp )} {/* Title */} -
{renderTextLines(cfg.title)}
+ {cfg.title && ( +
{renderTextLines(cfg.title)}
+ )} {/* Subtitle */} -
{renderTextLines(cfg.subtitle)}
+ {cfg.subtitle && ( +
{renderTextLines(cfg.subtitle)}
+ )} {/* Mask overlay */} {cfg.maskEnabled && ( -- 2.54.0 From 618ffd82a4f729d413131973925c14a4588f0337 Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Fri, 25 Sep 2026 18:08:01 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(cover):=20=E5=8E=BB=E9=99=A4=20any=20?= =?UTF-8?q?=E7=B1=BB=E5=9E=8B=EF=BC=8C=E4=BF=AE=E5=A4=8D=20eslint=20warnin?= =?UTF-8?q?g?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/src/components/cover/useSharedCover.ts | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/apps/web/src/components/cover/useSharedCover.ts b/apps/web/src/components/cover/useSharedCover.ts index 063fdfcdd..29a174c15 100644 --- a/apps/web/src/components/cover/useSharedCover.ts +++ b/apps/web/src/components/cover/useSharedCover.ts @@ -76,16 +76,15 @@ export function useSharedCover(opts: UseSharedCoverOptions): UseSharedCoverRetur setTemplatesLoading(true) setTemplatesError(null) try { - const res: any = await fetchCoverTemplates() + const res = await fetchCoverTemplates() // 兼容两种响应:{items:[...]} 或直接数组 - const list = Array.isArray(res) ? res : res?.items || [] - // 过滤掉后端可能返回的没有 config 的模板(避免编辑器打开时访问 .title.text 崩溃) - setTemplates( - list.map((t: any) => ({ - ...t, - config: t.config || undefined, - })), - ) + const rawList = (res as unknown as { items?: CoverTemplate[] }).items ?? [] + // 确保每个模板都有 config 字段(避免编辑器打开时访问 cfg.title.text 崩溃) + const list: CoverTemplate[] = rawList.map((t) => ({ + ...t, + config: t.config, + })) + setTemplates(list) } catch (err) { console.error("[SharedCover] 加载封面模板失败:", err) setTemplatesError("加载模板失败,请稍后重试") -- 2.54.0