2026-04-18-00-23-14 - 修复拖拽关键帧边框残留、替换prompt为自定义弹窗、增加占位符图片来源隔离
This commit is contained in:
@@ -66,6 +66,15 @@ export default function ReportEditor() {
|
||||
const [imagePickerOpen, setImagePickerOpen] = useState(false);
|
||||
const [imagePickerTarget, setImagePickerTarget] = useState<HTMLElement | null>(null);
|
||||
const [imageAssets, setImageAssets] = useState<{id: string; name: string; dataUrl: string}[]>([]);
|
||||
const [tableModalOpen, setTableModalOpen] = useState(false);
|
||||
const [imageModalOpen, setImageModalOpen] = useState(false);
|
||||
const [savedRange, setSavedRange] = useState<Range | null>(null);
|
||||
const [imageModalInTable, setImageModalInTable] = useState(false);
|
||||
const [imageModalWidth, setImageModalWidth] = useState('200');
|
||||
const [imageModalHeight, setImageModalHeight] = useState('200');
|
||||
const [imageModalAllowSource, setImageModalAllowSource] = useState<'all' | 'frame' | 'upload'>('all');
|
||||
const [tableRows, setTableRows] = useState('2');
|
||||
const [tableCols, setTableCols] = useState('3');
|
||||
|
||||
const editorRef = useRef<HTMLDivElement>(null);
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
@@ -403,6 +412,11 @@ export default function ReportEditor() {
|
||||
if (!placeholder.classList.contains('has-image')) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const allowSource = placeholder.getAttribute('data-allow-source') || 'all';
|
||||
if (allowSource === 'frame') {
|
||||
alert('此区域仅限插入关键帧图片,请从右侧视频分析面板拖拽或点击插入。');
|
||||
return;
|
||||
}
|
||||
setImagePickerTarget(placeholder);
|
||||
setImagePickerOpen(true);
|
||||
}
|
||||
@@ -478,30 +492,42 @@ export default function ReportEditor() {
|
||||
saveDraftToStorage();
|
||||
};
|
||||
|
||||
const insertTable = () => {
|
||||
const rowsStr = prompt('请输入行数:', '2');
|
||||
const colsStr = prompt('请输入列数:', '3');
|
||||
if (rowsStr && colsStr) {
|
||||
const rows = parseInt(rowsStr);
|
||||
const cols = parseInt(colsStr);
|
||||
if (isNaN(rows) || isNaN(cols)) return;
|
||||
|
||||
let table = '<table style="width: 100%; border-collapse: collapse; margin: 16px 0; table-layout: fixed;">';
|
||||
for (let i = 0; i < rows; i++) {
|
||||
table += '<tr>';
|
||||
for (let j = 0; j < cols; j++) {
|
||||
table += '<td style="padding: 8px; border: 1px solid #e2e8f0; vertical-align: top;">单元格</td>';
|
||||
}
|
||||
table += '</tr>';
|
||||
}
|
||||
table += '</table><p></p>';
|
||||
execCmd('insertHTML', table);
|
||||
}
|
||||
const openTableModal = () => {
|
||||
const sel = window.getSelection();
|
||||
if (sel && sel.rangeCount > 0) setSavedRange(sel.getRangeAt(0).cloneRange());
|
||||
setTableRows('2');
|
||||
setTableCols('3');
|
||||
setTableModalOpen(true);
|
||||
};
|
||||
|
||||
const insertImage = () => {
|
||||
editorRef.current?.focus();
|
||||
const confirmInsertTable = () => {
|
||||
const rows = parseInt(tableRows);
|
||||
const cols = parseInt(tableCols);
|
||||
if (isNaN(rows) || isNaN(cols) || rows <= 0 || cols <= 0) {
|
||||
setTableModalOpen(false);
|
||||
return;
|
||||
}
|
||||
if (savedRange) {
|
||||
const sel = window.getSelection();
|
||||
sel?.removeAllRanges();
|
||||
sel?.addRange(savedRange);
|
||||
}
|
||||
let table = '<table style="width: 100%; border-collapse: collapse; margin: 16px 0; table-layout: fixed;">';
|
||||
for (let i = 0; i < rows; i++) {
|
||||
table += '<tr>';
|
||||
for (let j = 0; j < cols; j++) {
|
||||
table += '<td style="padding: 8px; border: 1px solid #e2e8f0; vertical-align: top;">单元格</td>';
|
||||
}
|
||||
table += '</tr>';
|
||||
}
|
||||
table += '</table><p></p>';
|
||||
execCmd('insertHTML', table);
|
||||
setTableModalOpen(false);
|
||||
setSavedRange(null);
|
||||
};
|
||||
|
||||
const openImageModal = () => {
|
||||
editorRef.current?.focus();
|
||||
const sel = window.getSelection();
|
||||
let node: Node | null = sel?.anchorNode ?? null;
|
||||
let inTable = false;
|
||||
@@ -512,42 +538,41 @@ export default function ReportEditor() {
|
||||
}
|
||||
node = node.parentNode;
|
||||
}
|
||||
if (sel && sel.rangeCount > 0) setSavedRange(sel.getRangeAt(0).cloneRange());
|
||||
setImageModalInTable(inTable);
|
||||
setImageModalWidth('200');
|
||||
setImageModalHeight('200');
|
||||
setImageModalAllowSource('all');
|
||||
setImageModalOpen(true);
|
||||
};
|
||||
|
||||
let width = 200;
|
||||
let height = 200;
|
||||
if (!inTable) {
|
||||
while (true) {
|
||||
const input = prompt('请输入占位符的最大宽度和高度(px),用*号分隔(如: 100*50)。留空则默认宽高为 200*200。(提示: 正文一行文字高度约为 20 像素左右)', '');
|
||||
if (input === null) return;
|
||||
const trimmed = input.trim();
|
||||
if (trimmed === '') break;
|
||||
const parts = trimmed.split('*').map(s => s.trim());
|
||||
if (parts.length === 2 && /^\d+$/.test(parts[0]) && /^\d+$/.test(parts[1])) {
|
||||
width = parseInt(parts[0]) || 0;
|
||||
height = parseInt(parts[1]) || 0;
|
||||
break;
|
||||
}
|
||||
alert('格式错误,请确保使用 * 分隔两个数字,例如 100*50');
|
||||
}
|
||||
const confirmInsertImage = () => {
|
||||
if (savedRange) {
|
||||
const sel = window.getSelection();
|
||||
sel?.removeAllRanges();
|
||||
sel?.addRange(savedRange);
|
||||
}
|
||||
|
||||
const width = imageModalInTable ? 0 : (parseInt(imageModalWidth) || 200);
|
||||
const height = imageModalInTable ? 0 : (parseInt(imageModalHeight) || 200);
|
||||
const allowSource = imageModalAllowSource;
|
||||
const hintText = '插入/点击放置图片';
|
||||
const id = 'ph_' + Date.now();
|
||||
|
||||
const allowAttr = allowSource !== 'all' ? ` data-allow-source="${allowSource}"` : '';
|
||||
let html: string;
|
||||
if (inTable) {
|
||||
if (imageModalInTable) {
|
||||
const styleStr = 'display:flex;align-items:center;justify-content:center;border:1px dashed #cbd5e1;background:#f8fafc;cursor:pointer;width:100%;height:100%;max-width:200px;max-height:200px;min-height:60px;margin:0 auto;';
|
||||
html = `<div id="${id}" class="image-placeholder" data-placeholder="true" contenteditable="false" style="${styleStr}"><span class="delete-btn" contenteditable="false">×</span><span class="placeholder-text" style="color:#94a3b8;font-size:11px;pointer-events:none;">${hintText}</span></div>`;
|
||||
html = `<div id="${id}" class="image-placeholder" data-placeholder="true" contenteditable="false"${allowAttr} style="${styleStr}"><span class="delete-btn" contenteditable="false">×</span><span class="placeholder-text" style="color:#94a3b8;font-size:11px;pointer-events:none;">${hintText}</span></div>`;
|
||||
} else {
|
||||
let styleStr = 'display:inline-flex;align-items:center;justify-content:center;border:1px dashed #cbd5e1;background:#f8fafc;vertical-align:middle;margin:0 4px;cursor:pointer;';
|
||||
if (width > 0) styleStr += `width:${width}px;`;
|
||||
if (height > 0) styleStr += `height:${height}px;`;
|
||||
const showShortText = width > 0 && width < 80;
|
||||
const text = showShortText ? '插入图片' : hintText;
|
||||
html = `<span id="${id}" class="image-placeholder" data-placeholder="true" contenteditable="false" style="${styleStr}"><span class="delete-btn" contenteditable="false">×</span><span class="placeholder-text" style="color:#94a3b8;font-size:11px;pointer-events:none;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">${text}</span></span>​`;
|
||||
html = `<span id="${id}" class="image-placeholder" data-placeholder="true" contenteditable="false"${allowAttr} style="${styleStr}"><span class="delete-btn" contenteditable="false">×</span><span class="placeholder-text" style="color:#94a3b8;font-size:11px;pointer-events:none;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">${text}</span></span>​`;
|
||||
}
|
||||
|
||||
execCmd('insertHTML', html);
|
||||
setImageModalOpen(false);
|
||||
setSavedRange(null);
|
||||
};
|
||||
|
||||
const handleVideoUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
@@ -678,11 +703,15 @@ export default function ReportEditor() {
|
||||
if (!editorRef.current) return;
|
||||
const emptyPlaceholder = editorRef.current.querySelector('.image-placeholder:not(.has-image)') as HTMLElement | null;
|
||||
if (emptyPlaceholder) {
|
||||
const allowSource = emptyPlaceholder.getAttribute('data-allow-source') || 'all';
|
||||
if (allowSource === 'upload') return;
|
||||
emptyPlaceholder.innerHTML = `
|
||||
<span class="delete-btn" contenteditable="false">×</span>
|
||||
<img src="${newFrame.dataUrl}" style="max-width: 100%; height: auto; display: block; margin: 0 auto;" draggable="false">
|
||||
`;
|
||||
emptyPlaceholder.classList.add('has-image');
|
||||
emptyPlaceholder.style.border = 'none';
|
||||
emptyPlaceholder.style.background = 'transparent';
|
||||
contentRef.current = editorRef.current.innerHTML;
|
||||
saveDraftToStorage();
|
||||
}
|
||||
@@ -712,12 +741,19 @@ export default function ReportEditor() {
|
||||
<img src="${frame.dataUrl}" style="max-width: 100%; height: auto; display: block; margin: 0 auto;" draggable="false">
|
||||
`;
|
||||
placeholder.classList.add('has-image');
|
||||
placeholder.style.border = 'none';
|
||||
placeholder.style.background = 'transparent';
|
||||
if (editorRef.current) contentRef.current = editorRef.current.innerHTML;
|
||||
saveDraftToStorage();
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent, placeholder: HTMLElement) => {
|
||||
e.preventDefault();
|
||||
const allowSource = placeholder.getAttribute('data-allow-source') || 'all';
|
||||
if (allowSource === 'upload') {
|
||||
alert('此区域仅限插入本地上传/签名/素材图片,不可置入关键帧。');
|
||||
return;
|
||||
}
|
||||
const frameId = e.dataTransfer.getData('frameId');
|
||||
const frame = capturedFrames.find(f => f.id.toString() === frameId);
|
||||
if (frame) {
|
||||
@@ -735,6 +771,11 @@ export default function ReportEditor() {
|
||||
alert('没有可插入图片的空位');
|
||||
return;
|
||||
}
|
||||
const allowSource = emptyPlaceholder.getAttribute('data-allow-source') || 'all';
|
||||
if (allowSource === 'upload') {
|
||||
alert('此区域仅限插入本地上传/签名/素材图片,不可通过关键帧插入。');
|
||||
return;
|
||||
}
|
||||
fillPlaceholder(emptyPlaceholder, frame);
|
||||
};
|
||||
|
||||
@@ -1369,8 +1410,8 @@ export default function ReportEditor() {
|
||||
<button onClick={() => execCmd('justifyRight')} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="右对齐"><AlignRight size={16} /></button>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<button onClick={insertTable} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="表格"><Table size={16} /></button>
|
||||
<button onClick={insertImage} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="插入图片占位符"><ImageIcon size={16} /></button>
|
||||
<button onMouseDown={(e) => e.preventDefault()} onClick={openTableModal} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="表格"><Table size={16} /></button>
|
||||
<button onMouseDown={(e) => e.preventDefault()} onClick={openImageModal} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="插入图片占位符"><ImageIcon size={16} /></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1895,6 +1936,65 @@ export default function ReportEditor() {
|
||||
</div>
|
||||
<canvas ref={canvasRef} className="hidden" />
|
||||
|
||||
{tableModalOpen && (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4 backdrop-blur-sm">
|
||||
<div className="bg-white rounded-2xl p-6 w-full max-w-[360px] shadow-2xl border border-border">
|
||||
<h3 className="text-lg font-bold text-text-main mb-4">插入表格</h3>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm text-text-muted mb-1">行数</label>
|
||||
<input type="number" min={1} value={tableRows} onChange={(e) => setTableRows(e.target.value)} className="input-minimal" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-text-muted mb-1">列数</label>
|
||||
<input type="number" min={1} value={tableCols} onChange={(e) => setTableCols(e.target.value)} className="input-minimal" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 flex justify-end gap-2">
|
||||
<button onClick={() => { setTableModalOpen(false); setSavedRange(null); }} className="px-4 py-2 bg-slate-100 text-slate-600 rounded text-sm">取消</button>
|
||||
<button onClick={confirmInsertTable} className="px-4 py-2 bg-accent text-white rounded text-sm font-semibold">确认</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{imageModalOpen && (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4 backdrop-blur-sm">
|
||||
<div className="bg-white rounded-2xl p-6 w-full max-w-[360px] shadow-2xl border border-border">
|
||||
<h3 className="text-lg font-bold text-text-main mb-4">插入图片占位符</h3>
|
||||
<div className="space-y-4">
|
||||
{imageModalInTable ? (
|
||||
<div className="text-sm text-text-muted">表格内占位符将自动填满单元格,无需设置尺寸。</div>
|
||||
) : (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm text-text-muted mb-1">宽度 (px)</label>
|
||||
<input type="number" min={1} value={imageModalWidth} onChange={(e) => setImageModalWidth(e.target.value)} className="input-minimal" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-text-muted mb-1">高度 (px)</label>
|
||||
<input type="number" min={1} value={imageModalHeight} onChange={(e) => setImageModalHeight(e.target.value)} className="input-minimal" />
|
||||
</div>
|
||||
<div className="text-xs text-slate-400">提示:正文一行文字高度约为 20 像素左右。</div>
|
||||
</>
|
||||
)}
|
||||
<div>
|
||||
<label className="block text-sm text-text-muted mb-1">允许图片来源</label>
|
||||
<select value={imageModalAllowSource} onChange={(e) => setImageModalAllowSource(e.target.value as 'all' | 'frame' | 'upload')} className="input-minimal">
|
||||
<option value="all">所有来源</option>
|
||||
<option value="frame">仅限关键帧</option>
|
||||
<option value="upload">仅限本地上传/签名/素材</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 flex justify-end gap-2">
|
||||
<button onClick={() => { setImageModalOpen(false); setSavedRange(null); }} className="px-4 py-2 bg-slate-100 text-slate-600 rounded text-sm">取消</button>
|
||||
<button onClick={confirmInsertImage} className="px-4 py-2 bg-accent text-white rounded text-sm font-semibold">确认</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{imagePickerOpen && imagePickerTarget && (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4 backdrop-blur-sm">
|
||||
<div className="bg-white rounded-2xl p-6 w-full max-w-[360px] shadow-2xl border border-border">
|
||||
|
||||
@@ -41,6 +41,14 @@ export default function TemplateManage() {
|
||||
const [formatDropdownOpen, setFormatDropdownOpen] = useState(false);
|
||||
const [newFormatDropdownOpen, setNewFormatDropdownOpen] = useState(false);
|
||||
const [imageAssets, setImageAssets] = useState<{ id: string; name: string; dataUrl: string }[]>([]);
|
||||
const [tableModalOpen, setTableModalOpen] = useState(false);
|
||||
const [imageModalOpen, setImageModalOpen] = useState(false);
|
||||
const [imageModalInTable, setImageModalInTable] = useState(false);
|
||||
const [imageModalWidth, setImageModalWidth] = useState('200');
|
||||
const [imageModalHeight, setImageModalHeight] = useState('200');
|
||||
const [imageModalAllowSource, setImageModalAllowSource] = useState<'all' | 'frame' | 'upload'>('all');
|
||||
const [tableRows, setTableRows] = useState('2');
|
||||
const [tableCols, setTableCols] = useState('3');
|
||||
|
||||
const updatePageHeight = () => {
|
||||
if (!editorRef.current) return;
|
||||
@@ -492,32 +500,44 @@ export default function TemplateManage() {
|
||||
e.target.value = '';
|
||||
};
|
||||
|
||||
const insertTable = () => {
|
||||
const rowsStr = prompt('请输入行数:', '2');
|
||||
const colsStr = prompt('请输入列数:', '3');
|
||||
if (rowsStr && colsStr) {
|
||||
const rows = parseInt(rowsStr);
|
||||
const cols = parseInt(colsStr);
|
||||
if (isNaN(rows) || isNaN(cols)) return;
|
||||
|
||||
let table = '<table style="width: 100%; border-collapse: collapse; margin: 16px 0; table-layout: fixed;">';
|
||||
for (let i = 0; i < rows; i++) {
|
||||
table += '<tr>';
|
||||
for (let j = 0; j < cols; j++) {
|
||||
table += '<td style="padding: 8px; border: 1px solid #e2e8f0; vertical-align: top;">单元格</td>';
|
||||
}
|
||||
table += '</tr>';
|
||||
}
|
||||
table += '</table><p></p>';
|
||||
pushHistory();
|
||||
execCmd('insertHTML', table);
|
||||
}
|
||||
const openTableModal = () => {
|
||||
const sel = window.getSelection();
|
||||
if (sel && sel.rangeCount > 0) savedRangeRef.current = sel.getRangeAt(0).cloneRange();
|
||||
setTableRows('2');
|
||||
setTableCols('3');
|
||||
setTableModalOpen(true);
|
||||
};
|
||||
|
||||
const insertImage = () => {
|
||||
const confirmInsertTable = () => {
|
||||
const rows = parseInt(tableRows);
|
||||
const cols = parseInt(tableCols);
|
||||
if (isNaN(rows) || isNaN(cols) || rows <= 0 || cols <= 0) {
|
||||
setTableModalOpen(false);
|
||||
return;
|
||||
}
|
||||
if (savedRangeRef.current) {
|
||||
const sel = window.getSelection();
|
||||
sel?.removeAllRanges();
|
||||
sel?.addRange(savedRangeRef.current);
|
||||
}
|
||||
let table = '<table style="width: 100%; border-collapse: collapse; margin: 16px 0; table-layout: fixed;">';
|
||||
for (let i = 0; i < rows; i++) {
|
||||
table += '<tr>';
|
||||
for (let j = 0; j < cols; j++) {
|
||||
table += '<td style="padding: 8px; border: 1px solid #e2e8f0; vertical-align: top;">单元格</td>';
|
||||
}
|
||||
table += '</tr>';
|
||||
}
|
||||
table += '</table><p></p>';
|
||||
pushHistory();
|
||||
execCmd('insertHTML', table);
|
||||
setTableModalOpen(false);
|
||||
savedRangeRef.current = null;
|
||||
};
|
||||
|
||||
const openImageModal = () => {
|
||||
editorRef.current?.focus();
|
||||
restoreSelection();
|
||||
|
||||
const sel = window.getSelection();
|
||||
let node: Node | null = sel?.anchorNode ?? null;
|
||||
let inTable = false;
|
||||
@@ -528,43 +548,37 @@ export default function TemplateManage() {
|
||||
}
|
||||
node = node.parentNode;
|
||||
}
|
||||
setImageModalInTable(inTable);
|
||||
setImageModalWidth('200');
|
||||
setImageModalHeight('200');
|
||||
setImageModalAllowSource('all');
|
||||
setImageModalOpen(true);
|
||||
};
|
||||
|
||||
let width = 200;
|
||||
let height = 200;
|
||||
if (!inTable) {
|
||||
while (true) {
|
||||
const input = prompt('请输入占位符的最大宽度和高度(px),用 * 分隔(如: 100*50)。留空则默认宽高为 200*200。(提示: 正文一行文字高度约为 20 像素左右)', '');
|
||||
if (input === null) return;
|
||||
const trimmed = input.trim();
|
||||
if (trimmed === '') break;
|
||||
const parts = trimmed.split('*').map(s => s.trim());
|
||||
if (parts.length === 2 && /^\d+$/.test(parts[0]) && /^\d+$/.test(parts[1])) {
|
||||
width = parseInt(parts[0]) || 0;
|
||||
height = parseInt(parts[1]) || 0;
|
||||
break;
|
||||
}
|
||||
alert('格式错误,请确保使用 * 分隔两个数字,例如 100*50');
|
||||
}
|
||||
}
|
||||
|
||||
const confirmInsertImage = () => {
|
||||
restoreSelection();
|
||||
const width = imageModalInTable ? 0 : (parseInt(imageModalWidth) || 200);
|
||||
const height = imageModalInTable ? 0 : (parseInt(imageModalHeight) || 200);
|
||||
const allowSource = imageModalAllowSource;
|
||||
const hintText = '插入/点击放置图片';
|
||||
const id = 'ph_' + Date.now();
|
||||
|
||||
const allowAttr = allowSource !== 'all' ? ` data-allow-source="${allowSource}"` : '';
|
||||
let html: string;
|
||||
if (inTable) {
|
||||
if (imageModalInTable) {
|
||||
const styleStr = 'display:flex;align-items:center;justify-content:center;border:1px dashed #cbd5e1;background:#f8fafc;cursor:pointer;width:100%;height:100%;max-width:200px;max-height:200px;min-height:60px;margin:0 auto;';
|
||||
html = `<div id="${id}" class="image-placeholder" data-placeholder="true" contenteditable="false" style="${styleStr}"><span class="delete-btn" contenteditable="false">×</span><span class="placeholder-text" style="color:#94a3b8;font-size:11px;pointer-events:none;">${hintText}</span></div>`;
|
||||
html = `<div id="${id}" class="image-placeholder" data-placeholder="true" contenteditable="false"${allowAttr} style="${styleStr}"><span class="delete-btn" contenteditable="false">×</span><span class="placeholder-text" style="color:#94a3b8;font-size:11px;pointer-events:none;">${hintText}</span></div>`;
|
||||
} else {
|
||||
let styleStr = 'display:inline-flex;align-items:center;justify-content:center;border:1px dashed #cbd5e1;background:#f8fafc;vertical-align:middle;margin:0 4px;cursor:pointer;';
|
||||
if (width > 0) styleStr += `width:${width}px;`;
|
||||
if (height > 0) styleStr += `height:${height}px;`;
|
||||
const showShortText = width > 0 && width < 80;
|
||||
const text = showShortText ? '插图' : hintText;
|
||||
html = `<span id="${id}" class="image-placeholder" data-placeholder="true" contenteditable="false" style="${styleStr}"><span class="delete-btn" contenteditable="false">×</span><span class="placeholder-text" style="color:#94a3b8;font-size:11px;pointer-events:none;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">${text}</span></span>​`;
|
||||
html = `<span id="${id}" class="image-placeholder" data-placeholder="true" contenteditable="false"${allowAttr} style="${styleStr}"><span class="delete-btn" contenteditable="false">×</span><span class="placeholder-text" style="color:#94a3b8;font-size:11px;pointer-events:none;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">${text}</span></span>​`;
|
||||
}
|
||||
|
||||
pushHistory();
|
||||
execCmd('insertHTML', html);
|
||||
setImageModalOpen(false);
|
||||
savedRangeRef.current = null;
|
||||
};
|
||||
|
||||
const saveCurrentTemplate = () => {
|
||||
@@ -815,8 +829,8 @@ export default function TemplateManage() {
|
||||
<button onMouseDown={(e) => e.preventDefault()} onClick={() => execCmd('justifyRight')} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="右对齐"><AlignRight size={16} /></button>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<button onMouseDown={(e) => e.preventDefault()} onClick={insertTable} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="插入表格"><Table size={16} /></button>
|
||||
<button onMouseDown={(e) => e.preventDefault()} onClick={insertImage} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="插入图片占位符"><ImageIcon size={16} /></button>
|
||||
<button onMouseDown={(e) => e.preventDefault()} onClick={openTableModal} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="插入表格"><Table size={16} /></button>
|
||||
<button onMouseDown={(e) => e.preventDefault()} onClick={openImageModal} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="插入图片占位符"><ImageIcon size={16} /></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1269,6 +1283,65 @@ export default function TemplateManage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tableModalOpen && (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4 backdrop-blur-sm">
|
||||
<div className="bg-white rounded-2xl p-6 w-full max-w-[360px] shadow-2xl border border-border">
|
||||
<h3 className="text-lg font-bold text-text-main mb-4">插入表格</h3>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm text-text-muted mb-1">行数</label>
|
||||
<input type="number" min={1} value={tableRows} onChange={(e) => setTableRows(e.target.value)} className="input-minimal" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-text-muted mb-1">列数</label>
|
||||
<input type="number" min={1} value={tableCols} onChange={(e) => setTableCols(e.target.value)} className="input-minimal" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 flex justify-end gap-2">
|
||||
<button onClick={() => { setTableModalOpen(false); savedRangeRef.current = null; }} className="px-4 py-2 bg-slate-100 text-slate-600 rounded text-sm">取消</button>
|
||||
<button onClick={confirmInsertTable} className="px-4 py-2 bg-accent text-white rounded text-sm font-semibold">确认</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{imageModalOpen && (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4 backdrop-blur-sm">
|
||||
<div className="bg-white rounded-2xl p-6 w-full max-w-[360px] shadow-2xl border border-border">
|
||||
<h3 className="text-lg font-bold text-text-main mb-4">插入图片占位符</h3>
|
||||
<div className="space-y-4">
|
||||
{imageModalInTable ? (
|
||||
<div className="text-sm text-text-muted">表格内占位符将自动填满单元格,无需设置尺寸。</div>
|
||||
) : (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm text-text-muted mb-1">宽度 (px)</label>
|
||||
<input type="number" min={1} value={imageModalWidth} onChange={(e) => setImageModalWidth(e.target.value)} className="input-minimal" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-text-muted mb-1">高度 (px)</label>
|
||||
<input type="number" min={1} value={imageModalHeight} onChange={(e) => setImageModalHeight(e.target.value)} className="input-minimal" />
|
||||
</div>
|
||||
<div className="text-xs text-slate-400">提示:正文一行文字高度约为 20 像素左右。</div>
|
||||
</>
|
||||
)}
|
||||
<div>
|
||||
<label className="block text-sm text-text-muted mb-1">允许图片来源</label>
|
||||
<select value={imageModalAllowSource} onChange={(e) => setImageModalAllowSource(e.target.value as 'all' | 'frame' | 'upload')} className="input-minimal">
|
||||
<option value="all">所有来源</option>
|
||||
<option value="frame">仅限关键帧</option>
|
||||
<option value="upload">仅限本地上传/签名/素材</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 flex justify-end gap-2">
|
||||
<button onClick={() => { setImageModalOpen(false); savedRangeRef.current = null; }} className="px-4 py-2 bg-slate-100 text-slate-600 rounded text-sm">取消</button>
|
||||
<button onClick={confirmInsertImage} className="px-4 py-2 bg-accent text-white rounded text-sm font-semibold">确认</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{imagePickerOpen && imagePickerTarget && (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4 backdrop-blur-sm">
|
||||
<div className="bg-white rounded-2xl p-6 w-full max-w-[360px] shadow-2xl border border-border">
|
||||
|
||||
278
工程分析/实现方案-2026-04-18-00-23-14.md
Normal file
278
工程分析/实现方案-2026-04-18-00-23-14.md
Normal file
@@ -0,0 +1,278 @@
|
||||
# 实现方案 — 2026-04-18-00-23-14
|
||||
|
||||
## 根因分析
|
||||
|
||||
1. **拖拽后边框残留**:`ReportEditor.tsx` 中存在两个填充函数:
|
||||
- `fillPlaceholderSrc`(弹窗选择图片后填充)会执行 `placeholder.style.border = 'none'` 和 `placeholder.style.background = 'transparent'`。
|
||||
- `fillPlaceholder`(拖拽关键帧后填充)仅添加 `has-image` class,未清除内联样式,导致 `style="border:1px dashed #cbd5e1;background:#f8fafc"` 仍然生效,覆盖 CSS class 的 `border-none`/`bg-transparent`。
|
||||
- 同理,`autoCaptureFrames` 的 `setTimeout` 回调中也直接操作 DOM,未清除内联样式。
|
||||
|
||||
2. **原生 `prompt()` 体验差**:`insertTable` 和 `insertImage` 在两端编辑器中均连续调用 `prompt()`,阻塞主线程且无法定制样式,与系统现代化 UI 脱节。
|
||||
|
||||
3. **占位符无来源隔离**:当前所有 `.image-placeholder` 生成时没有任何类型标识,导致关键帧、本地上传、签名等图片可以无差别填入任何位置。
|
||||
|
||||
## 修改文件清单
|
||||
|
||||
- `src/pages/ReportEditor.tsx`
|
||||
- `src/pages/TemplateManage.tsx`
|
||||
|
||||
## 具体代码变更
|
||||
|
||||
### 一、ReportEditor.tsx
|
||||
|
||||
#### 1. 新增弹窗状态
|
||||
|
||||
```typescript
|
||||
const [tableModalOpen, setTableModalOpen] = useState(false);
|
||||
const [imageModalOpen, setImageModalOpen] = useState(false);
|
||||
const [savedRange, setSavedRange] = useState<Range | null>(null);
|
||||
const [imageModalInTable, setImageModalInTable] = useState(false);
|
||||
const [imageModalWidth, setImageModalWidth] = useState('200');
|
||||
const [imageModalHeight, setImageModalHeight] = useState('200');
|
||||
const [imageModalAllowSource, setImageModalAllowSource] = useState<'all' | 'frame' | 'upload'>('all');
|
||||
const [tableRows, setTableRows] = useState('2');
|
||||
const [tableCols, setTableCols] = useState('3');
|
||||
```
|
||||
|
||||
#### 2. 修复 `fillPlaceholder`(F1)
|
||||
|
||||
在 `fillPlaceholder` 函数中,添加 `has-image` class 后,同步清除内联样式:
|
||||
|
||||
```typescript
|
||||
placeholder.classList.add('has-image');
|
||||
placeholder.style.border = 'none';
|
||||
placeholder.style.background = 'transparent';
|
||||
```
|
||||
|
||||
#### 3. 修复 `autoCaptureFrames` 中的自动插入(F2)
|
||||
|
||||
在 `setTimeout` 回调内,`classList.add('has-image')` 之后增加:
|
||||
|
||||
```typescript
|
||||
emptyPlaceholder.style.border = 'none';
|
||||
emptyPlaceholder.style.background = 'transparent';
|
||||
```
|
||||
|
||||
#### 4. 替换 `insertTable` 为弹窗驱动(F3)
|
||||
|
||||
- **打开弹窗**:
|
||||
```typescript
|
||||
const openTableModal = () => {
|
||||
const sel = window.getSelection();
|
||||
if (sel && sel.rangeCount > 0) setSavedRange(sel.getRangeAt(0).cloneRange());
|
||||
setTableRows('2');
|
||||
setTableCols('3');
|
||||
setTableModalOpen(true);
|
||||
};
|
||||
```
|
||||
- **确认插入**:
|
||||
```typescript
|
||||
const confirmInsertTable = () => {
|
||||
const rows = parseInt(tableRows);
|
||||
const cols = parseInt(tableCols);
|
||||
if (isNaN(rows) || isNaN(cols) || rows <= 0 || cols <= 0) {
|
||||
setTableModalOpen(false);
|
||||
return;
|
||||
}
|
||||
if (savedRange) {
|
||||
const sel = window.getSelection();
|
||||
sel?.removeAllRanges();
|
||||
sel?.addRange(savedRange);
|
||||
}
|
||||
let table = '<table style="width: 100%; border-collapse: collapse; margin: 16px 0; table-layout: fixed;">';
|
||||
for (let i = 0; i < rows; i++) {
|
||||
table += '<tr>';
|
||||
for (let j = 0; j < cols; j++) {
|
||||
table += '<td style="padding: 8px; border: 1px solid #e2e8f0; vertical-align: top;">单元格</td>';
|
||||
}
|
||||
table += '</tr>';
|
||||
}
|
||||
table += '</table><p></p>';
|
||||
execCmd('insertHTML', table);
|
||||
setTableModalOpen(false);
|
||||
setSavedRange(null);
|
||||
};
|
||||
```
|
||||
- 工具栏按钮 `onClick` 从 `insertTable` 改为 `openTableModal`。
|
||||
|
||||
#### 5. 替换 `insertImage` 为弹窗驱动(F4 / F5)
|
||||
|
||||
- **打开弹窗**:
|
||||
```typescript
|
||||
const openImageModal = () => {
|
||||
editorRef.current?.focus();
|
||||
const sel = window.getSelection();
|
||||
let node: Node | null = sel?.anchorNode ?? null;
|
||||
let inTable = false;
|
||||
while (node) {
|
||||
if ((node as Element).nodeName === 'TD' || (node as Element).nodeName === 'TH') {
|
||||
inTable = true;
|
||||
break;
|
||||
}
|
||||
node = node.parentNode;
|
||||
}
|
||||
if (sel && sel.rangeCount > 0) setSavedRange(sel.getRangeAt(0).cloneRange());
|
||||
setImageModalInTable(inTable);
|
||||
setImageModalWidth('200');
|
||||
setImageModalHeight('200');
|
||||
setImageModalAllowSource('all');
|
||||
setImageModalOpen(true);
|
||||
};
|
||||
```
|
||||
- **确认插入**:
|
||||
```typescript
|
||||
const confirmInsertImage = () => {
|
||||
if (savedRange) {
|
||||
const sel = window.getSelection();
|
||||
sel?.removeAllRanges();
|
||||
sel?.addRange(savedRange);
|
||||
}
|
||||
const width = imageModalInTable ? 0 : (parseInt(imageModalWidth) || 200);
|
||||
const height = imageModalInTable ? 0 : (parseInt(imageModalHeight) || 200);
|
||||
const allowSource = imageModalAllowSource;
|
||||
const hintText = '插入/点击放置图片';
|
||||
const id = 'ph_' + Date.now();
|
||||
const allowAttr = allowSource !== 'all' ? ` data-allow-source="${allowSource}"` : '';
|
||||
let html: string;
|
||||
if (imageModalInTable) {
|
||||
const styleStr = 'display:flex;align-items:center;justify-content:center;border:1px dashed #cbd5e1;background:#f8fafc;cursor:pointer;width:100%;height:100%;max-width:200px;max-height:200px;min-height:60px;margin:0 auto;';
|
||||
html = `<div id="${id}" class="image-placeholder" data-placeholder="true" contenteditable="false"${allowAttr} style="${styleStr}"><span class="delete-btn" contenteditable="false">×</span><span class="placeholder-text" style="color:#94a3b8;font-size:11px;pointer-events:none;">${hintText}</span></div>`;
|
||||
} else {
|
||||
let styleStr = 'display:inline-flex;align-items:center;justify-content:center;border:1px dashed #cbd5e1;background:#f8fafc;vertical-align:middle;margin:0 4px;cursor:pointer;';
|
||||
if (width > 0) styleStr += `width:${width}px;`;
|
||||
if (height > 0) styleStr += `height:${height}px;`;
|
||||
const showShortText = width > 0 && width < 80;
|
||||
const text = showShortText ? '插入图片' : hintText;
|
||||
html = `<span id="${id}" class="image-placeholder" data-placeholder="true" contenteditable="false"${allowAttr} style="${styleStr}"><span class="delete-btn" contenteditable="false">×</span><span class="placeholder-text" style="color:#94a3b8;font-size:11px;pointer-events:none;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;">${text}</span></span>​`;
|
||||
}
|
||||
execCmd('insertHTML', html);
|
||||
setImageModalOpen(false);
|
||||
setSavedRange(null);
|
||||
};
|
||||
```
|
||||
- 工具栏按钮 `onClick` 从 `insertImage` 改为 `openImageModal`。
|
||||
|
||||
#### 6. 拦截拖拽(F6)
|
||||
|
||||
修改 `handleDrop`:
|
||||
|
||||
```typescript
|
||||
const handleDrop = (e: React.DragEvent, placeholder: HTMLElement) => {
|
||||
e.preventDefault();
|
||||
const allowSource = placeholder.getAttribute('data-allow-source') || 'all';
|
||||
if (allowSource === 'upload') {
|
||||
alert('此区域仅限插入本地上传/签名/素材图片,不可置入关键帧。');
|
||||
return;
|
||||
}
|
||||
const frameId = e.dataTransfer.getData('frameId');
|
||||
const frame = capturedFrames.find(f => f.id.toString() === frameId);
|
||||
if (frame) fillPlaceholder(placeholder, frame);
|
||||
};
|
||||
```
|
||||
|
||||
#### 7. 拦截点击空占位符(F7)
|
||||
|
||||
修改 `handleEditorClick` 中点击空占位符的分支:
|
||||
|
||||
```typescript
|
||||
if (!placeholder.classList.contains('has-image')) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const allowSource = placeholder.getAttribute('data-allow-source') || 'all';
|
||||
if (allowSource === 'frame') {
|
||||
alert('此区域仅限插入关键帧图片,请从右侧视频分析面板拖拽或点击插入。');
|
||||
return;
|
||||
}
|
||||
setImagePickerTarget(placeholder);
|
||||
setImagePickerOpen(true);
|
||||
}
|
||||
```
|
||||
|
||||
#### 8. 拦截一键插入关键帧(F8)
|
||||
|
||||
修改 `insertFrameToPlaceholder`:
|
||||
|
||||
```typescript
|
||||
const insertFrameToPlaceholder = (frame: CapturedFrame) => {
|
||||
if (!editorRef.current) {
|
||||
alert('编辑器未准备好');
|
||||
return;
|
||||
}
|
||||
const emptyPlaceholder = editorRef.current.querySelector('.image-placeholder:not(.has-image)') as HTMLElement | null;
|
||||
if (!emptyPlaceholder) {
|
||||
alert('没有可插入图片的空位');
|
||||
return;
|
||||
}
|
||||
const allowSource = emptyPlaceholder.getAttribute('data-allow-source') || 'all';
|
||||
if (allowSource === 'upload') {
|
||||
alert('此区域仅限插入本地上传/签名/素材图片,不可通过关键帧插入。');
|
||||
return;
|
||||
}
|
||||
fillPlaceholder(emptyPlaceholder, frame);
|
||||
};
|
||||
```
|
||||
|
||||
#### 9. 拦截自动帧插入(F9)
|
||||
|
||||
在 `autoCaptureFrames` 的 `setTimeout` 回调中,读取 `data-allow-source`,若值为 `upload` 则直接 `return` 跳过该帧。
|
||||
|
||||
#### 10. 新增 JSX 弹窗(位于组件底部)
|
||||
|
||||
**Table Modal**:
|
||||
- 遮罩层:`fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4 backdrop-blur-sm`
|
||||
- 卡片:`bg-white rounded-2xl p-6 w-full max-w-[360px] shadow-2xl`
|
||||
- 输入:行数、列数(`<input type="number">`)
|
||||
- 按钮:确认(`btn-accent` 样式)、取消
|
||||
|
||||
**Image Placeholder Modal**:
|
||||
- 同样遮罩层和卡片布局
|
||||
- 若在表格内:显示提示文本「表格内占位符将自动填满单元格,无需设置尺寸」。
|
||||
- 若不在表格内:宽度输入、高度输入(`type="number"`,默认 200),提示文字「正文一行文字高度约为 20 像素左右」。
|
||||
- 下拉选择:「允许图片来源」——所有来源 / 仅限关键帧 / 仅限本地上传/签名/素材。
|
||||
- 按钮:确认、取消
|
||||
|
||||
### 二、TemplateManage.tsx
|
||||
|
||||
#### 1. 新增弹窗状态
|
||||
|
||||
与 ReportEditor 类似,但使用已有的 `savedRangeRef` 恢复光标:
|
||||
|
||||
```typescript
|
||||
const [tableModalOpen, setTableModalOpen] = useState(false);
|
||||
const [imageModalOpen, setImageModalOpen] = useState(false);
|
||||
const [imageModalInTable, setImageModalInTable] = useState(false);
|
||||
const [imageModalWidth, setImageModalWidth] = useState('200');
|
||||
const [imageModalHeight, setImageModalHeight] = useState('200');
|
||||
const [imageModalAllowSource, setImageModalAllowSource] = useState<'all' | 'frame' | 'upload'>('all');
|
||||
const [tableRows, setTableRows] = useState('2');
|
||||
const [tableCols, setTableCols] = useState('3');
|
||||
```
|
||||
|
||||
#### 2. 替换 `insertTable`
|
||||
|
||||
- `openTableModal`:保存 `savedRangeRef.current`,打开弹窗。
|
||||
- `confirmInsertTable`:先 `restoreSelection()`,再执行 `pushHistory()` + `execCmd('insertHTML', table)`。
|
||||
|
||||
#### 3. 替换 `insertImage`
|
||||
|
||||
- `openImageModal`:检测是否在表格内,保存 `savedRangeRef.current`,打开弹窗。
|
||||
- `confirmInsertImage`:先 `restoreSelection()`,再执行 `pushHistory()` + `execCmd('insertHTML', html)`。
|
||||
- HTML 中增加 `data-allow-source` 属性。
|
||||
|
||||
#### 4. 新增 JSX 弹窗
|
||||
|
||||
结构与 ReportEditor 完全一致,放置在组件底部 `imagePickerOpen` 弹窗之前或之后。
|
||||
|
||||
## 风险点与应对措施
|
||||
|
||||
| 风险 | 应对措施 |
|
||||
|------|---------|
|
||||
| 弹窗打开后编辑器失去焦点,插入位置错误 | 打开弹窗前保存 `Range.cloneRange()`,确认后恢复 `Selection` 再执行 `insertHTML`。 |
|
||||
| `autoCaptureFrames` 的 `setTimeout` 异步回调中 DOM 引用失效 | 回调内部重新查询 `editorRef.current`,并做空值保护;`contentRef.current` 同步更新。 |
|
||||
| 旧报告/模板中的占位符没有 `data-allow-source` 属性 | 所有读取逻辑使用 `getAttribute('data-allow-source') || 'all'` 兜底,向后兼容。 |
|
||||
| TemplateManage 工具栏按钮 `onMouseDown={e=>e.preventDefault()}` 已存在,ReportEditor 缺少 | 给 ReportEditor 的工具栏按钮也增加 `onMouseDown={e=>e.preventDefault()}`,减少焦点流失概率。 |
|
||||
|
||||
## 回滚策略
|
||||
|
||||
- 所有修改集中在两个文件(`ReportEditor.tsx`、`TemplateManage.tsx`),未改动 `types.ts`、`storage.ts` 等底层模块。
|
||||
- 回滚时直接 `git checkout` 还原两个文件即可恢复原有 `prompt()` 行为和占位符逻辑。
|
||||
47
工程分析/测试方案-2026-04-18-00-23-14.md
Normal file
47
工程分析/测试方案-2026-04-18-00-23-14.md
Normal file
@@ -0,0 +1,47 @@
|
||||
# 测试方案 — 2026-04-18-00-23-14
|
||||
|
||||
## 测试目标
|
||||
|
||||
验证以下 3 个需求是否按预期工作:
|
||||
1. 拖拽关键帧到 `.image-placeholder` 后,占位符内联边框和背景被彻底清除。
|
||||
2. 「插入表格」和「插入图片占位符」改为自定义居中弹窗,且插入位置准确。
|
||||
3. 图片占位符支持「图片来源限制」,各限制类型在拖拽、点击、一键插入、自动插入场景下均被正确拦截或放行。
|
||||
|
||||
## 测试环境
|
||||
|
||||
- 本地开发服务器:`npm run dev`(端口 3000)
|
||||
- 浏览器:Chrome / Edge(推荐)
|
||||
- 测试账号:任意账号(建议 `admin` / `123456`)
|
||||
|
||||
## 测试用例
|
||||
|
||||
| 编号 | 场景 | 操作步骤 | 预期结果 |
|
||||
|------|------|---------|---------|
|
||||
| TC-01 | 拖拽关键帧后边框清除 | 1. 进入「报告编辑」页。<br>2. 上传视频并自动/手动截取关键帧。<br>3. 插入一个图片占位符。<br>4. 从右侧视频分析面板拖拽关键帧到占位符。 | 占位符内的虚线框和浅灰背景完全消失,图片正常显示,无残留边框。 |
|
||||
| TC-02 | 自动帧插入后边框清除 | 1. 开启「自动帧插入」。<br>2. 上传新视频触发自动摘帧。<br>3. 观察自动插入到占位符的关键帧。 | 自动插入的图片同样无残留边框和背景。 |
|
||||
| TC-03 | ReportEditor 插入表格弹窗 | 1. 点击工具栏「表格」按钮。<br>2. 在弹窗中输入 3 行 4 列,点击确认。 | 页面中央弹出模态框;确认后表格正确插入到光标所在位置。 |
|
||||
| TC-04 | ReportEditor 插入图片占位符弹窗 | 1. 点击工具栏「插入图片占位符」按钮。<br>2. 在弹窗中输入宽 150、高 100,选择「所有来源」,点击确认。 | 页面中央弹出模态框;确认后行内占位符(150×100)插入到光标位置,且可正常点击上传图片。 |
|
||||
| TC-05 | TemplateManage 插入表格弹窗 | 1. 进入「模板管理」。<br>2. 点击工具栏「表格」按钮,输入 2 行 2 列确认。 | 弹窗正常弹出,表格插入位置准确。 |
|
||||
| TC-06 | TemplateManage 插入图片占位符弹窗 | 1. 进入「模板管理」。<br>2. 点击工具栏「插入图片占位符」,输入宽 80、高 80,确认。 | 弹窗正常弹出,占位符插入后显示「插图」缩写文本。 |
|
||||
| TC-07 | 表格内插入占位符隐藏尺寸 | 1. 在表格单元格内点击。<br>2. 点击「插入图片占位符」。 | 弹窗中提示「表格内占位符将自动填满单元格,无需设置尺寸」,不显示宽高输入框。 |
|
||||
| TC-08 | 仅限关键帧占位符 | 1. 插入占位符时选择「仅限关键帧」。<br>2. 点击该空占位符。 | 弹出提示「此区域仅限插入关键帧图片...」,不打开图片选择器。 |
|
||||
| TC-09 | 仅限关键帧-拖拽放行 | 1. 对「仅限关键帧」占位符,从右侧拖拽关键帧放入。 | 关键帧正常插入,无报错。 |
|
||||
| TC-10 | 仅限关键帧-上传拦截 | 1. 对「仅限关键帧」占位符,尝试点击打开图片选择器。 | 被拦截并提示。 |
|
||||
| TC-11 | 仅限上传类占位符 | 1. 插入占位符时选择「仅限本地上传/签名/素材」。<br>2. 点击该空占位符。 | 正常弹出「本地上传/签名/素材」三选一弹窗。 |
|
||||
| TC-12 | 仅限上传类-拖拽拦截 | 1. 对「仅限上传类」占位符,从右侧拖拽关键帧放入。 | 弹出提示「此区域仅限插入本地上传/签名/素材图片,不可置入关键帧。」,拒绝插入。 |
|
||||
| TC-13 | 一键插入拦截 | 1. 插入一个「仅限上传类」占位符作为第一个空位。<br>2. 在右侧关键帧卡片点击「插入」按钮。 | 弹出提示,拒绝插入。 |
|
||||
| TC-14 | 自动帧插入跳过受限占位符 | 1. 插入一个「仅限上传类」占位符。<br>2. 开启自动帧插入,上传视频触发自动摘帧。 | 第一个空占位符因限制为 upload 而跳过,不插入关键帧。 |
|
||||
| TC-15 | 向后兼容 | 1. 打开一份旧报告(无 `data-allow-source` 的占位符)。<br>2. 拖拽关键帧和点击上传。 | 旧占位符行为不变,两种操作均可正常执行。 |
|
||||
|
||||
## 验收标准
|
||||
|
||||
- [ ] TC-01 ~ TC-02:拖拽/自动插入后占位符内联边框和背景完全清除。
|
||||
- [ ] TC-03 ~ TC-07:ReportEditor 和 TemplateManage 的表格/图片占位符弹窗正常工作,焦点恢复无误。
|
||||
- [ ] TC-08 ~ TC-10:「仅限关键帧」占位符正确拦截上传类操作,放行关键帧操作。
|
||||
- [ ] TC-11 ~ TC-14:「仅限上传类」占位符正确拦截关键帧操作,放行上传类操作。
|
||||
- [ ] TC-15:旧数据无 `data-allow-source` 时默认行为不受影响。
|
||||
- [ ] `npm run lint` 无 TypeScript 编译错误。
|
||||
|
||||
## 测试方式
|
||||
|
||||
手工验证。本项目无自动化测试框架,所有用例通过浏览器交互逐项确认。
|
||||
31
工程分析/经验记录.md
31
工程分析/经验记录.md
@@ -888,3 +888,34 @@ if ((settings.autoInsertDelay || 0) > 0) {
|
||||
- 当 `<input list>` + `<datalist>` 的交互体验无法满足需求时,应尽早替换为自定义下拉组件,避免在不同浏览器中产生不一致的行为。
|
||||
- `document.execCommand('insertHTML')` 对块级元素边界(尤其是 `<td>` 内)的自动修正行为不可控;在表格等复杂容器内插入 HTML 时,应优先使用块级标签(如 `<div>`)作为外层容器,减少被浏览器重新排列的风险。
|
||||
- 打印样式的边距控制必须使用 `@page { margin: ... }` 而非 `body { padding: ... }`,前者会让打印引擎为每一页物理纸张独立分配边距,后者只在文档首尾生效一次。
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 记录 29:拖拽关键帧后占位符边框残留、prompt 弹窗改造与图片来源隔离
|
||||
|
||||
**A. 具体问题**
|
||||
1. 拖拽关键帧到 `.image-placeholder` 后,占位符原有的虚线框和浅灰背景未清除,图片与边框重叠。
|
||||
2. `insertTable` 和 `insertImage` 在 ReportEditor 和 TemplateManage 中均使用原生 `prompt()`,交互体验差且无法定制样式。
|
||||
3. 所有图片占位符无差别接受任何图片来源(关键帧、本地上传、签名、素材),导致签名等区域可能被误拖入术中截图。
|
||||
|
||||
**B. 产生问题原因**
|
||||
1. `fillPlaceholder`(拖拽填充)仅添加 `has-image` class,未同步清除内联 `border` 和 `background`;而 `fillPlaceholderSrc`(弹窗填充)做了清除,两者逻辑不一致。`autoCaptureFrames` 的自动插入回调同样遗漏了样式清除。
|
||||
2. 早期实现为快速落地,直接使用浏览器原生 `prompt()` 获取行列数和宽高,未考虑 UI 一致性。
|
||||
3. 占位符生成时缺少类型标识属性,导致后续拖拽、点击、自动插入等入口均无法做来源隔离。
|
||||
|
||||
**C. 解决问题方案**
|
||||
1. **统一清除内联样式**:在 `fillPlaceholder`、`autoCaptureFrames` 的 `setTimeout` 回调中,填充图片后统一执行 `placeholder.style.border = 'none'` 和 `placeholder.style.background = 'transparent'`。
|
||||
2. **自定义弹窗替换 prompt**:
|
||||
- 两端编辑器各新增 `tableModalOpen` / `imageModalOpen` 等状态,渲染固定遮罩 + 白色圆角卡片的居中弹窗。
|
||||
- 打开弹窗前保存当前 `Selection.getRangeAt(0).cloneRange()`,确认后恢复 Range 再执行 `execCmd('insertHTML', ...)`,确保插入位置准确。
|
||||
- 工具栏按钮增加 `onMouseDown={(e) => e.preventDefault()}`,减少焦点流失。
|
||||
3. **图片来源隔离**:
|
||||
- 插入占位符弹窗增加「允许图片来源」下拉框(所有来源 / 仅限关键帧 / 仅限本地上传/签名/素材),生成 HTML 时写入 `data-allow-source` 属性。
|
||||
- 在 `handleDrop`(拖拽)、`handleEditorClick`(点击空占位符)、`insertFrameToPlaceholder`(一键插入)、`autoCaptureFrames`(自动插入)四个入口读取该属性并拦截不符合来源的操作,给出明确提示。
|
||||
- 旧占位符未设置该属性时默认 `'all'`,保证向后兼容。
|
||||
|
||||
**D. 后续如何避免问题**
|
||||
- 当同一类 DOM 元素存在多种填充路径(拖拽、弹窗、自动插入)时,应将样式清除、class 切换等通用逻辑集中到单一函数(如 `fillPlaceholder`),避免重复代码和维护遗漏。
|
||||
- 在 `contentEditable` 编辑器中引入自定义弹窗时,必须配套「保存 → 恢复 Selection/Range」机制,否则弹窗导致的焦点丢失会让 `insertHTML` 插入到错误位置。
|
||||
- 任何需要权限/来源隔离的控件,应在生成时就通过 `data-*` 属性打上标签,并在所有消费入口(拖拽、点击、快捷键、自动逻辑)统一校验,而不是仅在单一入口拦截。
|
||||
|
||||
42
工程分析/需求分析-2026-04-18-00-23-14.md
Normal file
42
工程分析/需求分析-2026-04-18-00-23-14.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# 需求分析 — 2026-04-18-00-23-14
|
||||
|
||||
## 原始需求摘要
|
||||
|
||||
用户提出 3 个关于富文本编辑器(ReportEditor / TemplateManage)的优化需求:
|
||||
|
||||
1. **拖拽关键帧后占位符边框残留**:通过拖拽将视频关键帧放入 `.image-placeholder` 后,占位符原有的虚线框和背景色未完全清除,视觉兼容性差。
|
||||
2. **废弃原生 `prompt()`,改为居中 UI 弹窗**:点击「插入表格」和「插入图片占位符」时,当前使用浏览器原生 `prompt()` 弹窗,希望改为屏幕中央的自定义 React 弹窗,以确认表格行列数或占位符最大长宽。
|
||||
3. **占位符图片来源隔离与保护**:创建图片占位符时,可选择允许的图片来源类型(关键帧图片 / 本地上传/签名/素材),从而保护签名等特定区域不被误拖入术中截图。
|
||||
|
||||
## 需求拆解
|
||||
|
||||
### 功能点
|
||||
|
||||
- **F1**:修复 `fillPlaceholder`(拖拽填充)未清除内联 `border` 和 `background` 的问题,使图片完全撑满占位符。
|
||||
- **F2**:修复 `autoCaptureFrames` 中自动插入关键帧时同样未清除内联样式的问题。
|
||||
- **F3**:在 `ReportEditor.tsx` 和 `TemplateManage.tsx` 中,将 `insertTable` 的原生 `prompt()` 替换为自定义居中弹窗(输入行数、列数)。
|
||||
- **F4**:在 `ReportEditor.tsx` 和 `TemplateManage.tsx` 中,将 `insertImage` 的原生 `prompt()` 替换为自定义居中弹窗(输入宽度、高度;表格内自动隐藏尺寸输入)。
|
||||
- **F5**:在弹窗中增加「图片来源限制」下拉选项(所有来源 / 仅限关键帧 / 仅限本地上传/签名/素材),生成占位符时写入 `data-allow-source` 属性。
|
||||
- **F6**:在 `handleDrop`(拖拽关键帧)中拦截:若占位符限制为 `upload`,拒绝拖入并提示。
|
||||
- **F7**:在 `handleEditorClick`(点击空占位符)中拦截:若占位符限制为 `frame`,拒绝打开图片选择器并提示。
|
||||
- **F8**:在 `insertFrameToPlaceholder`(一键插入关键帧)中拦截:若目标占位符限制为 `upload`,拒绝插入并提示。
|
||||
- **F9**:在 `autoCaptureFrames` 的自动帧插入 `setTimeout` 中拦截:若第一个空置占位符限制为 `upload`,跳过该帧不插入。
|
||||
|
||||
### 非功能点
|
||||
|
||||
- 向后兼容:未设置 `data-allow-source` 的旧占位符默认行为不变(视为 `all`)。
|
||||
- 焦点管理:打开弹窗前保存当前 Selection/Range,确认后恢复光标位置再执行 `insertHTML`,确保插入位置正确。
|
||||
- 视觉一致性:弹窗样式与现有 `imagePickerOpen` 弹窗保持一致(固定遮罩 + 白色圆角卡片 + 居中布局)。
|
||||
- 零新依赖:不引入第三方 UI 库,继续使用原生 React state + Tailwind CSS 实现。
|
||||
|
||||
## 影响范围
|
||||
|
||||
| 模块 | 影响程度 | 说明 |
|
||||
|------|---------|------|
|
||||
| `src/pages/ReportEditor.tsx` | 高 | 修改 `fillPlaceholder`、`insertTable`、`insertImage`、`handleDrop`、`handleEditorClick`、`insertFrameToPlaceholder`、`autoCaptureFrames`;新增弹窗 state 与 JSX。 |
|
||||
| `src/pages/TemplateManage.tsx` | 高 | 修改 `insertTable`、`insertImage`;新增弹窗 state 与 JSX;复用 `savedRangeRef` 做光标恢复。 |
|
||||
| `src/index.css` | 低 | 无需修改,`.image-placeholder.has-image` 的 Tailwind 样式已正确,只需在 JS 中清除内联样式。 |
|
||||
|
||||
## 待确认问题
|
||||
|
||||
无。用户已明确要求本次不经过人工二次确认直接执行。
|
||||
Reference in New Issue
Block a user