Improve MindSpace visual editor UX and default RMB billing.

Add color-coded edit highlights with an in-preview legend, strip CSP meta
for srcdoc editing, and simplify fullscreen preview to immersive visual edit
only. Default billing to CNY token rates unless H5_USE_BACKEND_COST=1.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
John
2026-06-15 23:47:23 -07:00
parent 8d2f55771c
commit 79457230c1
11 changed files with 339 additions and 225 deletions
+7 -5
View File
@@ -23,6 +23,7 @@ H5_PUBLIC_BASE_URL=http://127.0.0.1:8081
DATABASE_URL=mysql://boot:password@localhost:3306/tkmind DATABASE_URL=mysql://boot:password@localhost:3306/tkmind
# 或分别设置 MYSQL_HOST / MYSQL_PORT / MYSQL_USER / MYSQL_PASSWORD / MYSQL_DATABASE # 或分别设置 MYSQL_HOST / MYSQL_PORT / MYSQL_USER / MYSQL_PASSWORD / MYSQL_DATABASE
H5_USERS_ROOT=/root/tkmind_go/users H5_USERS_ROOT=/root/tkmind_go/users
# 新用户赠送余额(人民币分,500 = ¥5.00;勿与美元 cents 混淆)
H5_SIGNUP_BALANCE_CENTS=500 H5_SIGNUP_BALANCE_CENTS=500
H5_ADMIN_USERNAME=admin H5_ADMIN_USERNAME=admin
H5_ADMIN_PASSWORD=change-me-admin H5_ADMIN_PASSWORD=change-me-admin
@@ -47,12 +48,13 @@ H5_ADMIN_PASSWORD=change-me-admin
# H5_LOCAL_LLM_NAME=Local Ollama 7B # H5_LOCAL_LLM_NAME=Local Ollama 7B
H5_ACCESS_PASSWORD=change-me H5_ACCESS_PASSWORD=change-me
# 计费(Phase 2 # 计费(Phase 2,金额单位均为人民币分
# H5_USE_BACKEND_COST=1 # 优先用后端 accumulatedCostUSD)计费 # 默认按 Token 单价扣费(人民币结算);勿开启美元成本换算除非明确需要
# H5_USD_CNY_RATE=7.2 # H5_USE_BACKEND_COST=0
# H5_BILL_INPUT_CENTS_PER_1K=2 # H5_BILL_INPUT_CENTS_PER_1K=2 # 输入 Token2 分/1k(¥0.02/1k
# H5_BILL_OUTPUT_CENTS_PER_1K=6 # H5_BILL_OUTPUT_CENTS_PER_1K=6 # 输出 Token6 分/1k(¥0.06/1k
# H5_MIN_BILL_CENTS=1 # H5_MIN_BILL_CENTS=1
# 仅调试:H5_USE_BACKEND_COST=1 + H5_USD_CNY_RATE=7.2(上游 USD × 汇率 → 人民币分)
# 用户自助充值(微信支付) # 用户自助充值(微信支付)
# H5_RECHARGE_TIERS_CENTS=500,1000,3000,5000,10000,20000 # H5_RECHARGE_TIERS_CENTS=500,1000,3000,5000,10000,20000
+4 -1
View File
@@ -1,5 +1,6 @@
export function loadBillingConfig() { export function loadBillingConfig() {
const useBackendCost = process.env.H5_USE_BACKEND_COST !== '0'; // 默认按人民币分(CNY cents)计费;仅当 H5_USE_BACKEND_COST=1 时才用上游 USD 成本换算。
const useBackendCost = process.env.H5_USE_BACKEND_COST === '1';
return { return {
useBackendCost, useBackendCost,
usdCnyRate: Number(process.env.H5_USD_CNY_RATE ?? 7.2), usdCnyRate: Number(process.env.H5_USD_CNY_RATE ?? 7.2),
@@ -53,6 +54,8 @@ export function computeDeltaCostCents(previous, current, config = loadBillingCon
return Math.max(config.minBillCents, Math.ceil(currCost * config.usdCnyRate * 100)); return Math.max(config.minBillCents, Math.ceil(currCost * config.usdCnyRate * 100));
} }
// 默认路径:按 Token 增量 × 人民币单价(分/1k tokens)扣费。
const prevIn = Number(previous?.lastInputTokens ?? 0); const prevIn = Number(previous?.lastInputTokens ?? 0);
const prevOut = Number(previous?.lastOutputTokens ?? 0); const prevOut = Number(previous?.lastOutputTokens ?? 0);
const deltaIn = Math.max(0, current.accumulatedInputTokens - prevIn); const deltaIn = Math.max(0, current.accumulatedInputTokens - prevIn);
+13
View File
@@ -39,3 +39,16 @@ test('computeDeltaCostCents returns zero when no usage delta', () => {
}); });
assert.equal(computeDeltaCostCents(previous, current, config), 0); assert.equal(computeDeltaCostCents(previous, current, config), 0);
}); });
test('computeDeltaCostCents ignores backend USD cost unless explicitly enabled', () => {
const previous = { lastInputTokens: 0, lastOutputTokens: 0, lastAccumulatedCost: 0.01 };
const current = normalizeTokenState({
accumulatedInputTokens: 1000,
accumulatedOutputTokens: 500,
accumulatedCost: 0.02,
});
const rmbConfig = { ...config, useBackendCost: false };
assert.equal(computeDeltaCostCents(previous, current, rmbConfig), 5);
const usdConfig = { ...config, useBackendCost: true };
assert.equal(computeDeltaCostCents(previous, current, usdConfig), 8);
});
+11
View File
@@ -26,3 +26,14 @@ test('buildEditablePreviewDocument provides blank fallback', () => {
const result = buildEditablePreviewDocument(''); const result = buildEditablePreviewDocument('');
assert.match(result, /空白页面/); assert.match(result, /空白页面/);
}); });
test('buildEditablePreviewDocument strips CSP meta so editor script can run', () => {
const html = `<!DOCTYPE html><html><head>
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'none'">
</head><body><h1>探索世界</h1></body></html>`;
const result = buildEditablePreviewDocument(html);
assert.doesNotMatch(result, /Content-Security-Policy/);
assert.match(result, /mindspace-visual-editor-script/);
assert.match(result, /<h1>探索世界<\/h1>/);
});
+5 -9
View File
@@ -27,7 +27,6 @@ import { resolvePlazaPostUrl, resolvePublicPageUrl } from '../utils/publicUrl';
import { MindSpacePageDraftPreviewFrame, type MindSpacePreviewChangeHighlight } from './MindSpacePageDraftPreviewFrame'; import { MindSpacePageDraftPreviewFrame, type MindSpacePreviewChangeHighlight } from './MindSpacePageDraftPreviewFrame';
import { import {
MindSpacePageFullscreenPreview, MindSpacePageFullscreenPreview,
type MindSpacePageFullscreenPreviewChat,
} from './MindSpacePageFullscreenPreview'; } from './MindSpacePageFullscreenPreview';
import { MindSpacePagePreviewPanel } from './MindSpacePagePreviewPanel'; import { MindSpacePagePreviewPanel } from './MindSpacePagePreviewPanel';
import { MindSpaceModal } from './MindSpaceModal'; import { MindSpaceModal } from './MindSpaceModal';
@@ -96,7 +95,6 @@ export function MindSpacePageDetail({
onContextUpdate, onContextUpdate,
autoOpenPlaza = false, autoOpenPlaza = false,
refreshTrigger = 0, refreshTrigger = 0,
overlayChat = null,
onFullscreenPreviewChange, onFullscreenPreviewChange,
}: { }: {
pageId: string; pageId: string;
@@ -110,7 +108,6 @@ export function MindSpacePageDetail({
}) => void; }) => void;
autoOpenPlaza?: boolean; autoOpenPlaza?: boolean;
refreshTrigger?: number; refreshTrigger?: number;
overlayChat?: MindSpacePageFullscreenPreviewChat | null;
onFullscreenPreviewChange?: (open: boolean) => void; onFullscreenPreviewChange?: (open: boolean) => void;
}) { }) {
const [page, setPage] = useState<MindSpacePage | null>(null); const [page, setPage] = useState<MindSpacePage | null>(null);
@@ -550,6 +547,9 @@ export function MindSpacePageDetail({
const openFullscreenPreview = () => { const openFullscreenPreview = () => {
if (!page || page.contentFormat !== 'html' || !content.trim()) return; if (!page || page.contentFormat !== 'html' || !content.trim()) return;
setPreviewContent(content);
setPreviewRefreshPending(false);
setPreviewKey((value) => value + 1);
setFullscreenPreviewOpen(true); setFullscreenPreviewOpen(true);
onFullscreenPreviewChange?.(true); onFullscreenPreviewChange?.(true);
}; };
@@ -1071,7 +1071,7 @@ export function MindSpacePageDetail({
type="button" type="button"
onClick={openFullscreenPreview} onClick={openFullscreenPreview}
disabled={page.contentFormat !== 'html' || !content.trim()} disabled={page.contentFormat !== 'html' || !content.trim()}
title={page.contentFormat === 'html' ? '全屏可视化编辑,可配合 Agent 改页' : '仅 HTML 页面支持全屏编辑'} title={page.contentFormat === 'html' ? '进入沉浸式可视化编辑' : '仅 HTML 页面支持全屏编辑'}
> >
</button> </button>
@@ -1156,12 +1156,9 @@ export function MindSpacePageDetail({
{fullscreenPreviewOpen && page && page.contentFormat === 'html' ? ( {fullscreenPreviewOpen && page && page.contentFormat === 'html' ? (
<MindSpacePageFullscreenPreview <MindSpacePageFullscreenPreview
pageId={page.id}
pageTitle={title || page.title} pageTitle={title || page.title}
title={title} title={title}
summary={summary} content={content}
content={previewContent}
templateId={templateId}
reloadKey={previewKey} reloadKey={previewKey}
changeHighlight={changeHighlight} changeHighlight={changeHighlight}
canUndo={canUndo} canUndo={canUndo}
@@ -1172,7 +1169,6 @@ export function MindSpacePageDetail({
onRefresh={() => handleManualPreviewRefresh()} onRefresh={() => handleManualPreviewRefresh()}
refreshPending={previewRefreshPending} refreshPending={previewRefreshPending}
onContentChange={handlePreviewContentChange} onContentChange={handlePreviewContentChange}
chat={overlayChat}
/> />
) : null} ) : null}
</> </>
@@ -11,7 +11,9 @@ export function MindSpacePageEditablePreviewFrame({
reloadKey, reloadKey,
minHeight = 680, minHeight = 680,
maxHeight = 12000, maxHeight = 12000,
className = 'mindspace-page-preview-frame', className,
compact = false,
fillViewport = false,
showHint = true, showHint = true,
onContentChange, onContentChange,
}: { }: {
@@ -21,9 +23,14 @@ export function MindSpacePageEditablePreviewFrame({
minHeight?: number; minHeight?: number;
maxHeight?: number; maxHeight?: number;
className?: string; className?: string;
compact?: boolean;
fillViewport?: boolean;
showHint?: boolean; showHint?: boolean;
onContentChange: (html: string) => void; onContentChange: (html: string) => void;
}) { }) {
const scrollInsideFrame = compact || fillViewport;
const frameClassName =
className ?? (compact ? 'page-save-mini-page-frame' : 'mindspace-page-preview-frame');
const [previewFailed, setPreviewFailed] = useState(false); const [previewFailed, setPreviewFailed] = useState(false);
const [srcdoc, setSrcdoc] = useState(() => buildEditablePreviewDocument(content)); const [srcdoc, setSrcdoc] = useState(() => buildEditablePreviewDocument(content));
const [height, setHeight] = useState(minHeight); const [height, setHeight] = useState(minHeight);
@@ -62,6 +69,11 @@ export function MindSpacePageEditablePreviewFrame({
}, [onContentChange]); }, [onContentChange]);
useEffect(() => { useEffect(() => {
if (scrollInsideFrame) {
syncHeightRef.current = null;
return;
}
const iframe = iframeRef.current; const iframe = iframeRef.current;
if (!iframe || !srcdoc) return; if (!iframe || !srcdoc) return;
@@ -85,7 +97,7 @@ export function MindSpacePageEditablePreviewFrame({
syncHeightRef.current = null; syncHeightRef.current = null;
unbind(); unbind();
}; };
}, [maxHeight, minHeight, srcdoc]); }, [maxHeight, minHeight, scrollInsideFrame, srcdoc]);
if (previewFailed) { if (previewFailed) {
return <div className="mindspace-state mindspace-error"></div>; return <div className="mindspace-state mindspace-error"></div>;
@@ -95,7 +107,14 @@ export function MindSpacePageEditablePreviewFrame({
<> <>
{showHint ? ( {showHint ? (
<p className="mindspace-page-editable-hint"> <p className="mindspace-page-editable-hint">
· / · · <span className="mindspace-page-editable-legend-swatch mindspace-page-editable-legend-swatch-text" />
<span className="mindspace-page-editable-legend-swatch mindspace-page-editable-legend-swatch-media" />
/
<span className="mindspace-page-editable-legend-swatch mindspace-page-editable-legend-swatch-link" />
<span className="mindspace-page-editable-legend-swatch mindspace-page-editable-legend-swatch-bg" />
线
</p> </p>
) : null} ) : null}
<iframe <iframe
@@ -103,9 +122,9 @@ export function MindSpacePageEditablePreviewFrame({
title={`${title || '页面'} 预览`} title={`${title || '页面'} 预览`}
srcDoc={srcdoc} srcDoc={srcdoc}
sandbox="allow-same-origin allow-scripts" sandbox="allow-same-origin allow-scripts"
scrolling="no" scrolling={scrollInsideFrame ? 'yes' : 'no'}
className={className} className={frameClassName}
style={{ height: `${height}px` }} style={scrollInsideFrame ? { height: '100%' } : { height: `${height}px` }}
onError={() => setPreviewFailed(true)} onError={() => setPreviewFailed(true)}
/> />
</> </>
@@ -1,32 +1,11 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect } from 'react';
import { usePageEditSubChat } from '../hooks/usePageEditSubChat';
import type { MindSpaceChatContext, MindSpaceSaveCategory, PortalUser } from '../types';
import { MindSpacePageEditablePreviewFrame } from './MindSpacePageEditablePreviewFrame'; import { MindSpacePageEditablePreviewFrame } from './MindSpacePageEditablePreviewFrame';
import type { MindSpacePreviewChangeHighlight } from './MindSpacePageDraftPreviewFrame'; import type { MindSpacePreviewChangeHighlight } from './MindSpacePageDraftPreviewFrame';
import { MindSpaceSpaceChat } from './MindSpaceSpaceChat';
export type MindSpacePageFullscreenPreviewChat = {
context: MindSpaceChatContext;
user: PortalUser;
parentSessionId?: string | null;
h5ApiBase?: string | null;
onOpenFullChat: () => void;
onPageSaved?: (result: {
kind: 'page' | 'category';
pageId?: string;
categoryCode?: MindSpaceSaveCategory;
}) => void;
onSubSessionChange?: (sessionId: string | null) => void;
onForkUnavailable?: () => void;
};
export function MindSpacePageFullscreenPreview({ export function MindSpacePageFullscreenPreview({
pageId,
pageTitle, pageTitle,
title, title,
summary: _summary,
content, content,
templateId: _templateId,
reloadKey, reloadKey,
changeHighlight: _changeHighlight = null, changeHighlight: _changeHighlight = null,
canUndo = false, canUndo = false,
@@ -37,14 +16,10 @@ export function MindSpacePageFullscreenPreview({
onRefresh, onRefresh,
refreshPending = false, refreshPending = false,
onContentChange, onContentChange,
chat,
}: { }: {
pageId: string;
pageTitle: string; pageTitle: string;
title: string; title: string;
summary: string;
content: string; content: string;
templateId: string;
reloadKey: number; reloadKey: number;
changeHighlight?: MindSpacePreviewChangeHighlight | null; changeHighlight?: MindSpacePreviewChangeHighlight | null;
canUndo?: boolean; canUndo?: boolean;
@@ -55,40 +30,7 @@ export function MindSpacePageFullscreenPreview({
onRefresh: () => void; onRefresh: () => void;
refreshPending?: boolean; refreshPending?: boolean;
onContentChange: (html: string) => void; onContentChange: (html: string) => void;
chat?: MindSpacePageFullscreenPreviewChat | null;
}) { }) {
const [chatOpen, setChatOpen] = useState(true);
const [subChatFallback, setSubChatFallback] = useState(false);
const subChatEnabled = Boolean(chat?.parentSessionId);
useEffect(() => {
setSubChatFallback(false);
}, [pageId, chat?.parentSessionId]);
const subChat = usePageEditSubChat({
pageId,
pageTitle: pageTitle || title,
parentSessionId: chat?.parentSessionId,
h5ApiBase: chat?.h5ApiBase,
user: chat?.user,
enabled: subChatEnabled && !subChatFallback,
onSessionChange: chat?.onSubSessionChange,
onForkUnavailable: () => {
setSubChatFallback(true);
chat?.onForkUnavailable?.();
},
});
const subChatContext = useMemo(() => {
if (!chat?.context) return null;
return {
...chat.context,
pageEditMode: true,
parentAgentSessionId: chat.parentSessionId ?? chat.context.parentAgentSessionId,
agentSessionId: subChat.session?.id ?? chat.context.agentSessionId,
} satisfies MindSpaceChatContext;
}, [chat, subChat.session?.id]);
useEffect(() => { useEffect(() => {
const previousOverflow = document.body.style.overflow; const previousOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden'; document.body.style.overflow = 'hidden';
@@ -118,13 +60,13 @@ export function MindSpacePageFullscreenPreview({
return; return;
} }
if (event.key !== 'Escape') return; if (event.key === 'Escape') {
if (chatOpen) return; onClose();
onClose(); }
}; };
window.addEventListener('keydown', handleKeyDown); window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown);
}, [canRedo, canUndo, chatOpen, onClose, onRedo, onUndo]); }, [canRedo, canUndo, onClose, onRedo, onUndo]);
return ( return (
<div className="mindspace-page-fullscreen-preview" role="dialog" aria-modal="true" aria-label="页面全屏预览编辑"> <div className="mindspace-page-fullscreen-preview" role="dialog" aria-modal="true" aria-label="页面全屏预览编辑">
@@ -133,16 +75,10 @@ export function MindSpacePageFullscreenPreview({
<span className="mindspace-eyebrow">VISUAL EDIT</span> <span className="mindspace-eyebrow">VISUAL EDIT</span>
<strong>{pageTitle || '全屏预览编辑'}</strong> <strong>{pageTitle || '全屏预览编辑'}</strong>
<span className="mindspace-page-fullscreen-preview-hint"> <span className="mindspace-page-fullscreen-preview-hint">
· / · · = · =/ · = · 绿=
</span> </span>
</div> </div>
<div className="mindspace-page-fullscreen-preview-actions"> <div className="mindspace-page-fullscreen-preview-actions">
{subChatEnabled && !subChatFallback && subChat.chatState === 'loading' ? (
<span className="mindspace-page-preview-sync"> Agent</span>
) : null}
{subChatFallback ? (
<span className="mindspace-page-preview-sync">使 Agent </span>
) : null}
<button type="button" onClick={onUndo} disabled={!canUndo} title="撤销 (⌘Z)"> <button type="button" onClick={onUndo} disabled={!canUndo} title="撤销 (⌘Z)">
</button> </button>
@@ -168,28 +104,12 @@ export function MindSpacePageFullscreenPreview({
title={title} title={title}
content={content} content={content}
reloadKey={reloadKey} reloadKey={reloadKey}
minHeight={720} fillViewport
maxHeight={12000}
className="mindspace-page-fullscreen-preview-frame" className="mindspace-page-fullscreen-preview-frame"
showHint={false} showHint={false}
onContentChange={onContentChange} onContentChange={onContentChange}
/> />
</div> </div>
{chat && subChatContext ? (
<MindSpaceSpaceChat
context={subChatContext}
user={chat.user}
onOpenFullChat={chat.onOpenFullChat}
onPageSaved={chat.onPageSaved}
open={chatOpen}
onOpenChange={setChatOpen}
variant="overlay"
chatBridge={subChatEnabled && !subChatFallback ? subChat : undefined}
hideOpenFullChat={!subChatFallback}
title={subChatFallback ? 'TKMind' : '页面编辑 Agent'}
/>
) : null}
</div> </div>
); );
} }
+15 -10
View File
@@ -118,19 +118,24 @@ export function MindSpacePagePreviewPanel({
return ( return (
<div className="page-save-preview-dual mindspace-page-preview-dual"> <div className="page-save-preview-dual mindspace-page-preview-dual">
<div className="page-save-preview-pane page-save-preview-pane-page"> <div
className="page-save-preview-pane page-save-preview-pane-page"
onWheelCapture={(event) => event.stopPropagation()}
>
<span className="page-save-preview-label"></span> <span className="page-save-preview-label"></span>
<p className="page-save-preview-hint"> <p className="page-save-preview-hint">
· / · · · 线= · = · = · 绿=
</p> </p>
<MindSpacePageEditablePreviewFrame <div className="page-save-mini-page">
title={title} <MindSpacePageEditablePreviewFrame
content={content} title={title}
reloadKey={reloadKey} content={content}
showHint={false} reloadKey={reloadKey}
className="mindspace-page-preview-frame" compact
onContentChange={onContentChange} showHint={false}
/> onContentChange={onContentChange}
/>
</div>
</div> </div>
<div className="page-save-preview-pane page-save-preview-pane-thumb"> <div className="page-save-preview-pane page-save-preview-pane-thumb">
<span className="page-save-preview-label"></span> <span className="page-save-preview-label"></span>
+6 -61
View File
@@ -199,8 +199,6 @@ export function MindSpaceView({
} | null>(null); } | null>(null);
const [pageRefreshTrigger, setPageRefreshTrigger] = useState(0); const [pageRefreshTrigger, setPageRefreshTrigger] = useState(0);
const [pageFullscreenPreviewOpen, setPageFullscreenPreviewOpen] = useState(false); const [pageFullscreenPreviewOpen, setPageFullscreenPreviewOpen] = useState(false);
const [pageEditSubSessionId, setPageEditSubSessionId] = useState<string | null>(null);
const [pageEditSubSessionActive, setPageEditSubSessionActive] = useState(false);
const { chatState, messages, session } = useChat(); const { chatState, messages, session } = useChat();
const prevChatStateRef = useRef(chatState); const prevChatStateRef = useRef(chatState);
const h5ApiBase = useMemo(() => resolveH5ApiBase(), []); const h5ApiBase = useMemo(() => resolveH5ApiBase(), []);
@@ -218,22 +216,13 @@ export function MindSpaceView({
if (!selectedPageId) { if (!selectedPageId) {
setPageLiveContext(null); setPageLiveContext(null);
setPageFullscreenPreviewOpen(false); setPageFullscreenPreviewOpen(false);
setPageEditSubSessionId(null);
setPageEditSubSessionActive(false);
} }
}, [selectedPageId]); }, [selectedPageId]);
useEffect(() => { useEffect(() => {
if (!selectedPageId || !session?.id || previewMode) return; if (!selectedPageId || !session?.id || previewMode || pageFullscreenPreviewOpen) return;
if (pageFullscreenPreviewOpen && pageEditSubSessionActive) return;
void bindMindSpacePageLiveEdit(selectedPageId, session.id).catch(() => {}); void bindMindSpacePageLiveEdit(selectedPageId, session.id).catch(() => {});
}, [ }, [selectedPageId, session?.id, previewMode, pageFullscreenPreviewOpen]);
selectedPageId,
session?.id,
previewMode,
pageFullscreenPreviewOpen,
pageEditSubSessionActive,
]);
const mindspaceChatContext = useMemo(() => { const mindspaceChatContext = useMemo(() => {
if (!space) return null; if (!space) return null;
@@ -247,10 +236,8 @@ export function MindSpaceView({
assets, assets,
focusedAsset: agentAsset, focusedAsset: agentAsset,
route: `${location.pathname}${location.search}`, route: `${location.pathname}${location.search}`,
agentSessionId: pageEditSubSessionId ?? session?.id ?? null, agentSessionId: session?.id ?? null,
h5ApiBase, h5ApiBase,
pageEditMode: pageFullscreenPreviewOpen,
parentAgentSessionId: pageFullscreenPreviewOpen ? session?.id ?? null : null,
}); });
}, [ }, [
space, space,
@@ -265,8 +252,6 @@ export function MindSpaceView({
location.search, location.search,
session?.id, session?.id,
h5ApiBase, h5ApiBase,
pageFullscreenPreviewOpen,
pageEditSubSessionId,
]); ]);
const truncateLabel = (text: string, max = 48) => { const truncateLabel = (text: string, max = 48) => {
@@ -537,10 +522,10 @@ export function MindSpaceView({
const previous = prevChatStateRef.current; const previous = prevChatStateRef.current;
prevChatStateRef.current = chatState; prevChatStateRef.current = chatState;
const wasBusy = previous === 'streaming' || previous === 'waiting'; const wasBusy = previous === 'streaming' || previous === 'waiting';
if (!wasBusy || chatState !== 'idle' || !selectedPageId || previewMode) return; if (!wasBusy || chatState !== 'idle' || !selectedPageId || previewMode || pageFullscreenPreviewOpen) return;
setPageRefreshTrigger((value) => value + 1); setPageRefreshTrigger((value) => value + 1);
void refreshPagesSilently(); void refreshPagesSilently();
}, [chatState, selectedPageId, previewMode]); }, [chatState, pageFullscreenPreviewOpen, previewMode, selectedPageId]);
useEffect(() => { useEffect(() => {
void load(); void load();
@@ -1174,47 +1159,7 @@ export function MindSpaceView({
onContextUpdate={setPageLiveContext} onContextUpdate={setPageLiveContext}
autoOpenPlaza={selectedPageAutoPlaza} autoOpenPlaza={selectedPageAutoPlaza}
refreshTrigger={pageRefreshTrigger} refreshTrigger={pageRefreshTrigger}
onFullscreenPreviewChange={(open) => { onFullscreenPreviewChange={setPageFullscreenPreviewOpen}
setPageFullscreenPreviewOpen(open);
if (!open) {
setPageEditSubSessionId(null);
setPageEditSubSessionActive(false);
}
}}
overlayChat={
mindspaceChatContext && session?.id
? {
context: mindspaceChatContext,
user,
parentSessionId: session.id,
h5ApiBase,
onOpenFullChat: onBack,
onSubSessionChange: (subSessionId) => {
setPageEditSubSessionId(subSessionId);
setPageEditSubSessionActive(Boolean(subSessionId));
},
onForkUnavailable: () => {
setPageEditSubSessionId(null);
setPageEditSubSessionActive(false);
if (selectedPageId && session?.id) {
void bindMindSpacePageLiveEdit(selectedPageId, session.id).catch(
() => {},
);
}
},
onPageSaved: (result) => {
if (result.kind === 'page' && result.pageId) {
void refreshPagesSilently();
if (result.pageId === selectedPageId) {
setPageRefreshTrigger((value) => value + 1);
} else {
showPage(result.pageId);
}
}
},
}
: null
}
/> />
) )
) : newPageOpen ? ( ) : newPageOpen ? (
+39 -2
View File
@@ -5442,6 +5442,10 @@ body,
width: 180px; width: 180px;
} }
.mindspace-page-preview-dual .page-save-mini-page {
height: min(380px, 46vh);
}
.mindspace-page-preview-dual + .mindspace-html-source-editor { .mindspace-page-preview-dual + .mindspace-html-source-editor {
margin-top: 12px; margin-top: 12px;
} }
@@ -5537,6 +5541,34 @@ body,
padding: 10px 18px 0; padding: 10px 18px 0;
color: #6d7771; color: #6d7771;
font-size: 12px; font-size: 12px;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.mindspace-page-editable-legend-swatch {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 2px;
flex-shrink: 0;
}
.mindspace-page-editable-legend-swatch-text {
background: rgba(61, 139, 253, 0.92);
}
.mindspace-page-editable-legend-swatch-media {
background: rgba(201, 132, 24, 0.94);
}
.mindspace-page-editable-legend-swatch-link {
background: rgba(124, 92, 255, 0.92);
}
.mindspace-page-editable-legend-swatch-bg {
background: rgba(47, 111, 87, 0.92);
} }
.mindspace-page-preview-loading { .mindspace-page-preview-loading {
@@ -6095,14 +6127,19 @@ body,
} }
.mindspace-page-fullscreen-preview-body { .mindspace-page-fullscreen-preview-body {
overflow: auto; overflow: hidden;
display: flex;
flex-direction: column;
min-height: 0;
background: #f4f1e8; background: #f4f1e8;
} }
.mindspace-page-fullscreen-preview-frame { .mindspace-page-fullscreen-preview-frame {
display: block; display: block;
flex: 1;
width: 100%; width: 100%;
min-height: 100%; min-height: 0;
height: 100%;
border: 0; border: 0;
background: #fff; background: #fff;
} }
+207 -44
View File
@@ -1,41 +1,92 @@
export const MINDSPACE_PAGE_CONTENT_MESSAGE = 'mindspace:page-content'; export const MINDSPACE_PAGE_CONTENT_MESSAGE = 'mindspace:page-content';
const EDITOR_STYLE = `<style id="mindspace-visual-editor-style"> const EDITOR_STYLE = `<style id="mindspace-visual-editor-style">
[data-mindspace-editing="true"] [contenteditable="true"] { [data-mindspace-editing="true"] [data-mindspace-edit-kind] {
outline: 1px dashed rgba(61, 139, 253, 0.45); position: relative;
}
[data-mindspace-editing="true"][data-mindspace-highlights="off"] [data-mindspace-edit-kind] {
outline: none !important;
box-shadow: none !important;
}
[data-mindspace-editing="true"][data-mindspace-highlights="off"] [data-mindspace-edit-kind]::after,
[data-mindspace-editing="true"][data-mindspace-highlights="off"] [data-mindspace-edit-kind]::before {
display: none !important;
}
[data-mindspace-editing="true"] [data-mindspace-edit-kind="text"] {
outline: 1px dashed rgba(61, 139, 253, 0.72);
outline-offset: 2px; outline-offset: 2px;
box-shadow: inset 0 0 0 1px rgba(61, 139, 253, 0.12);
cursor: text; cursor: text;
} }
[data-mindspace-editing="true"] [contenteditable="true"]:focus { [data-mindspace-editing="true"] [data-mindspace-edit-kind="text"]:focus {
outline: 2px solid rgba(61, 139, 253, 0.95); outline: 2px solid rgba(61, 139, 253, 0.95);
background: rgba(61, 139, 253, 0.06); background: rgba(61, 139, 253, 0.08);
} }
[data-mindspace-editing="true"] img, [data-mindspace-editing="true"] [data-mindspace-edit-kind="media"] {
[data-mindspace-editing="true"] video, outline: 2px dashed rgba(238, 176, 78, 0.82);
[data-mindspace-editing="true"] audio, outline-offset: 3px;
[data-mindspace-editing="true"] iframe[data-mindspace-media-target="true"] {
cursor: pointer; cursor: pointer;
outline: 1px dashed transparent; box-shadow: 0 0 0 1px rgba(238, 176, 78, 0.18);
}
[data-mindspace-editing="true"] [data-mindspace-edit-kind="media"]:hover {
outline-color: rgba(238, 176, 78, 1);
box-shadow: 0 0 0 4px rgba(238, 176, 78, 0.16);
}
[data-mindspace-editing="true"] [data-mindspace-edit-kind="link"] {
outline: 1px dotted rgba(124, 92, 255, 0.72);
outline-offset: 2px; outline-offset: 2px;
transition: outline-color 0.15s ease; text-decoration: underline dotted rgba(124, 92, 255, 0.55);
}
[data-mindspace-editing="true"] img:hover,
[data-mindspace-editing="true"] video:hover,
[data-mindspace-editing="true"] audio:hover,
[data-mindspace-editing="true"] iframe[data-mindspace-media-target="true"]:hover {
outline-color: rgba(238, 176, 78, 0.95);
}
[data-mindspace-editing="true"] a[data-mindspace-link-target="true"] {
cursor: text; cursor: text;
} }
[data-mindspace-editing="true"] a[data-mindspace-link-target="true"]:hover { [data-mindspace-editing="true"] [data-mindspace-edit-kind="link"]:hover {
text-decoration: underline dotted rgba(238, 176, 78, 0.85); outline-color: rgba(124, 92, 255, 0.95);
background: rgba(124, 92, 255, 0.08);
} }
[data-mindspace-editing="true"] [data-mindspace-bg-target="true"] { [data-mindspace-editing="true"] [data-mindspace-edit-kind="background"] {
outline: 1px dashed rgba(47, 111, 87, 0.55);
outline-offset: -2px;
cursor: copy; cursor: copy;
box-shadow: inset 0 0 0 9999px rgba(47, 111, 87, 0.04);
} }
[data-mindspace-editing="true"] [data-mindspace-bg-target="true"]:hover { [data-mindspace-editing="true"] [data-mindspace-edit-kind="background"]:hover {
box-shadow: inset 0 0 0 2px rgba(238, 176, 78, 0.45); outline-color: rgba(47, 111, 87, 0.95);
box-shadow: inset 0 0 0 9999px rgba(47, 111, 87, 0.1);
}
[data-mindspace-editing="true"] [data-mindspace-edit-kind]::after {
position: absolute;
top: 0;
left: 0;
z-index: 2147483640;
max-width: calc(100% - 8px);
padding: 2px 7px;
border-radius: 0 0 8px 0;
color: #fffaf0;
font: 10px/1.35 -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
letter-spacing: 0.02em;
white-space: nowrap;
pointer-events: none;
opacity: 0.92;
content: attr(data-mindspace-edit-label);
}
[data-mindspace-editing="true"] [data-mindspace-edit-kind="text"]::after {
background: rgba(61, 139, 253, 0.92);
}
[data-mindspace-editing="true"] [data-mindspace-edit-kind="media"]::after {
background: rgba(201, 132, 24, 0.94);
}
[data-mindspace-editing="true"] [data-mindspace-edit-kind="link"]::after {
background: rgba(124, 92, 255, 0.92);
}
[data-mindspace-editing="true"] [data-mindspace-edit-kind="background"]::after {
background: rgba(47, 111, 87, 0.92);
}
[data-mindspace-editing="true"] [data-mindspace-edit-kind="background"][data-mindspace-bg-large="true"]::before {
content: '';
position: absolute;
inset: 10px;
border: 1px dashed rgba(47, 111, 87, 0.35);
border-radius: 8px;
pointer-events: none;
} }
.mindspace-editor-popover { .mindspace-editor-popover {
position: fixed; position: fixed;
@@ -119,6 +170,54 @@ const EDITOR_STYLE = `<style id="mindspace-visual-editor-style">
box-shadow: 0 8px 28px rgba(24, 33, 29, 0.12); box-shadow: 0 8px 28px rgba(24, 33, 29, 0.12);
pointer-events: none; pointer-events: none;
} }
.mindspace-editor-legend {
position: fixed;
z-index: 2147483645;
top: 12px;
left: 12px;
display: grid;
gap: 8px;
width: min(280px, calc(100vw - 24px));
padding: 10px 12px;
border: 1px solid rgba(24, 33, 29, 0.12);
border-radius: 12px;
background: rgba(255, 252, 244, 0.96);
color: #18211d;
box-shadow: 0 10px 28px rgba(24, 33, 29, 0.12);
font: 11px/1.45 -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
.mindspace-editor-legend strong {
font-size: 12px;
}
.mindspace-editor-legend-items {
display: grid;
gap: 6px;
}
.mindspace-editor-legend-item {
display: flex;
align-items: center;
gap: 8px;
}
.mindspace-editor-legend-swatch {
width: 12px;
height: 12px;
border-radius: 3px;
flex-shrink: 0;
}
.mindspace-editor-legend-swatch[data-kind="text"] { background: rgba(61, 139, 253, 0.92); }
.mindspace-editor-legend-swatch[data-kind="media"] { background: rgba(201, 132, 24, 0.94); }
.mindspace-editor-legend-swatch[data-kind="link"] { background: rgba(124, 92, 255, 0.92); }
.mindspace-editor-legend-swatch[data-kind="background"] { background: rgba(47, 111, 87, 0.92); }
.mindspace-editor-legend-toggle {
justify-self: start;
border: 0;
border-radius: 999px;
padding: 5px 10px;
color: #52605a;
font: inherit;
background: rgba(24, 33, 29, 0.08);
cursor: pointer;
}
</style>`; </style>`;
const EDITOR_SCRIPT = `<script id="mindspace-visual-editor-script"> const EDITOR_SCRIPT = `<script id="mindspace-visual-editor-script">
@@ -140,19 +239,30 @@ const EDITOR_SCRIPT = `<script id="mindspace-visual-editor-script">
} }
function stripEditorArtifacts(root) { function stripEditorArtifacts(root) {
root.querySelectorAll('#mindspace-visual-editor-style,#mindspace-visual-editor-script,.mindspace-editor-popover,.mindspace-editor-guide').forEach(function (node) { root.querySelectorAll('#mindspace-visual-editor-style,#mindspace-visual-editor-script,.mindspace-editor-popover,.mindspace-editor-guide,.mindspace-editor-legend').forEach(function (node) {
node.remove(); node.remove();
}); });
root.querySelectorAll('[contenteditable]').forEach(function (node) { root.querySelectorAll('[contenteditable]').forEach(function (node) {
node.removeAttribute('contenteditable'); node.removeAttribute('contenteditable');
node.removeAttribute('spellcheck'); node.removeAttribute('spellcheck');
}); });
root.querySelectorAll('[data-mindspace-bg-target],[data-mindspace-link-target],[data-mindspace-media-target]').forEach(function (node) { root.querySelectorAll('[data-mindspace-bg-target],[data-mindspace-link-target],[data-mindspace-media-target],[data-mindspace-edit-kind],[data-mindspace-edit-label],[data-mindspace-bg-large]').forEach(function (node) {
node.removeAttribute('data-mindspace-bg-target'); node.removeAttribute('data-mindspace-bg-target');
node.removeAttribute('data-mindspace-link-target'); node.removeAttribute('data-mindspace-link-target');
node.removeAttribute('data-mindspace-media-target'); node.removeAttribute('data-mindspace-media-target');
node.removeAttribute('data-mindspace-edit-kind');
node.removeAttribute('data-mindspace-edit-label');
node.removeAttribute('data-mindspace-bg-large');
}); });
if (root.body) root.body.removeAttribute('data-mindspace-editing'); if (root.body) {
root.body.removeAttribute('data-mindspace-editing');
root.body.removeAttribute('data-mindspace-highlights');
}
}
function markEditable(el, kind, label) {
el.setAttribute('data-mindspace-edit-kind', kind);
el.setAttribute('data-mindspace-edit-label', label);
} }
function serializeDocument() { function serializeDocument() {
@@ -368,61 +478,103 @@ const EDITOR_SCRIPT = `<script id="mindspace-visual-editor-script">
function markBackgroundTargets() { function markBackgroundTargets() {
var nodes = document.querySelectorAll('body, main, section, article, header, footer, div'); var nodes = document.querySelectorAll('body, main, section, article, header, footer, div');
nodes.forEach(function (el) { nodes.forEach(function (el) {
if (el.closest('.mindspace-editor-popover,.mindspace-editor-guide')) return; if (el.closest('.mindspace-editor-popover,.mindspace-editor-guide,.mindspace-editor-legend')) return;
if (el.isContentEditable) return;
var style = window.getComputedStyle(el); var style = window.getComputedStyle(el);
var cls = String(el.className || ''); var cls = String(el.className || '');
var structural = /\\b(page|hero|cover|banner|bg|background|section|card|panel|wrap|container|shell|stage|canvas|frame|viewport)\\b/i.test(cls); var structural = /\\b(page|hero|cover|banner|bg|background|section|card|panel|wrap|container|shell|stage|canvas|frame|viewport)\\b/i.test(cls);
var painted = style.backgroundImage !== 'none' || (style.backgroundColor && style.backgroundColor !== 'rgba(0, 0, 0, 0)' && style.backgroundColor !== 'transparent'); var painted = style.backgroundImage !== 'none' || (style.backgroundColor && style.backgroundColor !== 'rgba(0, 0, 0, 0)' && style.backgroundColor !== 'transparent');
if (el.tagName === 'BODY' || structural || (painted && el.offsetWidth > 64 && el.offsetHeight > 64)) { if (el.tagName === 'BODY' || structural || (painted && el.offsetWidth > 64 && el.offsetHeight > 64)) {
el.setAttribute('data-mindspace-bg-target', 'true'); el.setAttribute('data-mindspace-bg-target', 'true');
markEditable(el, 'background', '点击换背景');
if (el.offsetWidth > 180 && el.offsetHeight > 120) {
el.setAttribute('data-mindspace-bg-large', 'true');
}
} }
}); });
} }
function markMediaTargets() { function markMediaTargets() {
document.querySelectorAll('img').forEach(function (img) {
if (img.closest('.mindspace-editor-popover,.mindspace-editor-guide,.mindspace-editor-legend')) return;
markEditable(img, 'media', '点击换图片');
});
document.querySelectorAll('video').forEach(function (video) {
if (video.closest('.mindspace-editor-popover,.mindspace-editor-guide,.mindspace-editor-legend')) return;
markEditable(video, 'media', '点击换视频');
});
document.querySelectorAll('audio').forEach(function (audio) {
if (audio.closest('.mindspace-editor-popover,.mindspace-editor-guide,.mindspace-editor-legend')) return;
markEditable(audio, 'media', '点击换音频');
});
document.querySelectorAll('iframe').forEach(function (iframe) { document.querySelectorAll('iframe').forEach(function (iframe) {
if (iframe.closest('.mindspace-editor-popover,.mindspace-editor-guide')) return; if (iframe.closest('.mindspace-editor-popover,.mindspace-editor-guide,.mindspace-editor-legend')) return;
iframe.setAttribute('data-mindspace-media-target', 'true'); iframe.setAttribute('data-mindspace-media-target', 'true');
markEditable(iframe, 'media', '点击换嵌入');
}); });
} }
function markLinkTargets() { function markLinkTargets() {
document.querySelectorAll('a[href]').forEach(function (anchor) { document.querySelectorAll('a[href]').forEach(function (anchor) {
if (anchor.closest('.mindspace-editor-popover,.mindspace-editor-guide')) return; if (anchor.closest('.mindspace-editor-popover,.mindspace-editor-guide,.mindspace-editor-legend')) return;
anchor.setAttribute('data-mindspace-link-target', 'true'); anchor.setAttribute('data-mindspace-link-target', 'true');
markEditable(anchor, 'link', '双击改链接');
}); });
} }
function enableTextEditing() { function enableTextEditing() {
document.querySelectorAll(TEXT_SELECTOR).forEach(function (el) { document.querySelectorAll(TEXT_SELECTOR).forEach(function (el) {
if (el.closest('.mindspace-editor-popover,.mindspace-editor-guide')) return; if (el.closest('.mindspace-editor-popover,.mindspace-editor-guide,.mindspace-editor-legend')) return;
if (el.querySelector && el.querySelector(MEDIA_BLOCK_SELECTOR)) return; if (el.querySelector && el.querySelector(MEDIA_BLOCK_SELECTOR)) return;
if (el.matches('a[data-mindspace-link-target="true"]')) return;
var textParent = el.closest('[data-mindspace-edit-kind="text"]');
if (textParent && textParent !== el) return;
el.setAttribute('contenteditable', 'true'); el.setAttribute('contenteditable', 'true');
el.setAttribute('spellcheck', 'true'); el.setAttribute('spellcheck', 'true');
markEditable(el, 'text', '点击改文字');
}); });
document.querySelectorAll('div').forEach(function (el) { document.querySelectorAll('div').forEach(function (el) {
if (el.closest('.mindspace-editor-popover,.mindspace-editor-guide')) return; if (el.closest('.mindspace-editor-popover,.mindspace-editor-guide,.mindspace-editor-legend')) return;
if (el.hasAttribute('contenteditable')) return; if (el.hasAttribute('contenteditable')) return;
if (el.querySelector(BLOCK_CHILD_SELECTOR)) return; if (el.querySelector(BLOCK_CHILD_SELECTOR)) return;
if (el.querySelector(MEDIA_BLOCK_SELECTOR)) return; if (el.querySelector(MEDIA_BLOCK_SELECTOR)) return;
if (!String(el.textContent || '').trim()) return; if (!String(el.textContent || '').trim()) return;
el.setAttribute('contenteditable', 'true'); el.setAttribute('contenteditable', 'true');
el.setAttribute('spellcheck', 'true'); el.setAttribute('spellcheck', 'true');
markEditable(el, 'text', '点击改文字');
}); });
} }
function addEditGuide() { function addEditLegend() {
var bar = document.createElement('div'); var legend = document.createElement('div');
bar.className = 'mindspace-editor-guide'; legend.className = 'mindspace-editor-legend';
bar.textContent = '文字:直接点击 · 图片/音视频:点击替换 · 背景:点击空白区域 · 链接:双击改地址'; legend.innerHTML =
document.body.appendChild(bar); '<strong>编辑模式 · 可修改区域</strong>' +
'<div class="mindspace-editor-legend-items">' +
'<div class="mindspace-editor-legend-item"><span class="mindspace-editor-legend-swatch" data-kind="text"></span><span>蓝色虚线 = 文字,直接点击修改</span></div>' +
'<div class="mindspace-editor-legend-item"><span class="mindspace-editor-legend-swatch" data-kind="media"></span><span>橙色虚线 = 图片/音视频,点击替换</span></div>' +
'<div class="mindspace-editor-legend-item"><span class="mindspace-editor-legend-swatch" data-kind="link"></span><span>紫色虚线 = 链接,双击改地址</span></div>' +
'<div class="mindspace-editor-legend-item"><span class="mindspace-editor-legend-swatch" data-kind="background"></span><span>绿色虚线 = 背景区域,点击换背景</span></div>' +
'</div>' +
'<button type="button" class="mindspace-editor-legend-toggle" data-action="toggle-highlights">隐藏标记</button>';
document.body.appendChild(legend);
legend.querySelector('[data-action="toggle-highlights"]').addEventListener('click', function () {
var hidden = document.body.getAttribute('data-mindspace-highlights') === 'off';
if (hidden) {
document.body.removeAttribute('data-mindspace-highlights');
this.textContent = '隐藏标记';
} else {
document.body.setAttribute('data-mindspace-highlights', 'off');
this.textContent = '显示标记';
}
});
} }
function onClick(event) { function onClick(event) {
var target = event.target; var target = event.target;
if (!(target instanceof Element)) return; if (!(target instanceof Element)) return;
if (target.closest('.mindspace-editor-popover,.mindspace-editor-guide')) return; if (target.closest('.mindspace-editor-popover,.mindspace-editor-guide,.mindspace-editor-legend')) return;
if (target.tagName === 'IMG') { if (target.tagName === 'IMG') {
event.preventDefault(); event.preventDefault();
@@ -461,7 +613,7 @@ const EDITOR_SCRIPT = `<script id="mindspace-visual-editor-script">
markBackgroundTargets(); markBackgroundTargets();
markMediaTargets(); markMediaTargets();
markLinkTargets(); markLinkTargets();
addEditGuide(); addEditLegend();
document.addEventListener('input', debounceEmit); document.addEventListener('input', debounceEmit);
document.addEventListener('blur', debounceEmit, true); document.addEventListener('blur', debounceEmit, true);
document.addEventListener('click', onClick, true); document.addEventListener('click', onClick, true);
@@ -483,21 +635,32 @@ const EDITOR_SCRIPT = `<script id="mindspace-visual-editor-script">
})(); })();
</script>`; </script>`;
/** Remove inline CSP meta tags so preview-edit bootstrap can run inside srcdoc iframes. */
export function stripPreviewEditCspMeta(html: string): string {
return String(html ?? '').replace(
/<meta\s+http-equiv\s*=\s*["']Content-Security-Policy["'][^>]*>/gi,
'',
);
}
export function buildEditablePreviewDocument(html: string): string { export function buildEditablePreviewDocument(html: string): string {
const source = String(html ?? '').trim(); const source = String(html ?? '').trim();
const cleaned = stripPreviewEditCspMeta(source)
.replace(/<style id="mindspace-visual-editor-style">[\s\S]*?<\/style>/gi, '')
.replace(/<script id="mindspace-visual-editor-script">[\s\S]*?<\/script>/gi, '');
const injection = `${EDITOR_STYLE}${EDITOR_SCRIPT}`; const injection = `${EDITOR_STYLE}${EDITOR_SCRIPT}`;
if (!source) { if (!cleaned) {
return buildEditablePreviewDocument( return buildEditablePreviewDocument(
'<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"></head><body><p>空白页面</p></body></html>', '<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"></head><body><p>空白页面</p></body></html>',
); );
} }
if (/<\/body>/i.test(source)) { if (/<\/body>/i.test(cleaned)) {
return source.replace(/<\/body>/i, `${injection}</body>`); return cleaned.replace(/<\/body>/i, `${injection}</body>`);
} }
if (/<\/html>/i.test(source)) { if (/<\/html>/i.test(cleaned)) {
return source.replace(/<\/html>/i, `${injection}</html>`); return cleaned.replace(/<\/html>/i, `${injection}</html>`);
} }
return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"></head><body>${source}${injection}</body></html>`; return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"></head><body>${cleaned}${injection}</body></html>`;
} }