- C1/C2: 移除 settings.py 中硬编码的数据库密码和 SECRET_KEY 默认值 - K8s: DB_PASSWORD/DB_HOST/DB_USER/DJANGO_SECRET_KEY 改为 secretKeyRef - H1: DEBUG 默认值从 True 改为 False - H2: 登录接口添加 ScopedRateThrottle (5/min),全局限流 (anon 30/min, user 120/min) - H4: Django Admin 仅在 DEBUG=True 时注册 - H6: PromptInput innerHTML 使用 DOMPurify 消毒防止 XSS - H7: ALLOWED_HOSTS 从 "*" 收紧为实际域名 - H9: Nginx 添加安全响应头 + server_tokens off - M1: 密码策略加强 (min 8 + CommonPassword + NumericPassword) - M5: Django 生产环境安全头配置 - L1: 登录接口改为 POST-only Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
405 lines
14 KiB
TypeScript
405 lines
14 KiB
TypeScript
import { useRef, useEffect, useCallback, useState } from 'react';
|
|
import DOMPurify from 'dompurify';
|
|
import { useInputBarStore } from '../store/inputBar';
|
|
import type { UploadedFile } from '../types';
|
|
import styles from './PromptInput.module.css';
|
|
|
|
const placeholders: Record<string, string> = {
|
|
universal: '上传参考图或视频,输入文字,通过 @ 引用素材',
|
|
keyframe: '输入描述,定义首帧到尾帧的运动过程',
|
|
};
|
|
|
|
export function PromptInput() {
|
|
const prompt = useInputBarStore((s) => s.prompt);
|
|
const setPrompt = useInputBarStore((s) => s.setPrompt);
|
|
const editorHtml = useInputBarStore((s) => s.editorHtml);
|
|
const setEditorHtml = useInputBarStore((s) => s.setEditorHtml);
|
|
const mode = useInputBarStore((s) => s.mode);
|
|
const references = useInputBarStore((s) => s.references);
|
|
const insertAtTrigger = useInputBarStore((s) => s.insertAtTrigger);
|
|
|
|
const editorRef = useRef<HTMLDivElement>(null);
|
|
const [showMentionPopup, setShowMentionPopup] = useState(false);
|
|
const [mentionPos, setMentionPos] = useState({ top: 0, left: 0 });
|
|
const typedAtRef = useRef(false); // tracks if popup was triggered by typing @
|
|
const [highlightedIdx, setHighlightedIdx] = useState(0);
|
|
const [hoverRef, setHoverRef] = useState<UploadedFile | null>(null);
|
|
const [hoverPos, setHoverPos] = useState({ top: 0, left: 0 });
|
|
|
|
// Auto-focus
|
|
useEffect(() => {
|
|
editorRef.current?.focus();
|
|
}, []);
|
|
|
|
// Sync editor when editorHtml changes (e.g. after submit or reEdit)
|
|
useEffect(() => {
|
|
const el = editorRef.current;
|
|
if (!el) return;
|
|
if (el.innerHTML !== editorHtml) {
|
|
el.innerHTML = DOMPurify.sanitize(editorHtml, { ALLOWED_TAGS: ['span', 'br'], ALLOWED_ATTR: ['class', 'contenteditable', 'data-ref-id', 'data-ref-type'] });
|
|
// If the HTML is plain text but we have references, rebuild mention spans
|
|
// This handles the case where editorHtml comes from backend (plain text only)
|
|
if (editorHtml && !editorHtml.includes('data-ref-id') && references.length > 0) {
|
|
rebuildMentionSpans(el);
|
|
}
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [editorHtml]);
|
|
|
|
// Handle @ button from toolbar
|
|
useEffect(() => {
|
|
if (insertAtTrigger === 0) return;
|
|
if (references.length === 0) return;
|
|
typedAtRef.current = false;
|
|
openMentionPopup();
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [insertAtTrigger]);
|
|
|
|
// Rebuild mention spans from plain text @label patterns
|
|
const rebuildMentionSpans = useCallback((el: HTMLElement) => {
|
|
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
|
|
const replacements: { node: Text; matches: { start: number; end: number; ref: UploadedFile }[] }[] = [];
|
|
|
|
let textNode: Text | null;
|
|
while ((textNode = walker.nextNode() as Text | null)) {
|
|
const text = textNode.textContent || '';
|
|
const matches: { start: number; end: number; ref: UploadedFile }[] = [];
|
|
for (const ref of references) {
|
|
const pattern = `@${ref.label}`;
|
|
let idx = text.indexOf(pattern);
|
|
while (idx !== -1) {
|
|
matches.push({ start: idx, end: idx + pattern.length, ref });
|
|
idx = text.indexOf(pattern, idx + pattern.length);
|
|
}
|
|
}
|
|
if (matches.length > 0) {
|
|
matches.sort((a, b) => a.start - b.start);
|
|
replacements.push({ node: textNode, matches });
|
|
}
|
|
}
|
|
|
|
for (const { node, matches } of replacements) {
|
|
const text = node.textContent || '';
|
|
const frag = document.createDocumentFragment();
|
|
let lastIdx = 0;
|
|
for (const m of matches) {
|
|
if (m.start > lastIdx) {
|
|
frag.appendChild(document.createTextNode(text.slice(lastIdx, m.start)));
|
|
}
|
|
const span = document.createElement('span');
|
|
span.className = styles.mention;
|
|
span.contentEditable = 'false';
|
|
span.dataset.refId = m.ref.id;
|
|
span.dataset.refType = m.ref.type;
|
|
span.textContent = `@${m.ref.label}`;
|
|
frag.appendChild(span);
|
|
lastIdx = m.end;
|
|
}
|
|
if (lastIdx < text.length) {
|
|
frag.appendChild(document.createTextNode(text.slice(lastIdx)));
|
|
}
|
|
node.parentNode?.replaceChild(frag, node);
|
|
}
|
|
|
|
if (replacements.length > 0) {
|
|
setEditorHtml(el.innerHTML);
|
|
}
|
|
}, [references, setEditorHtml]);
|
|
|
|
const openMentionPopup = useCallback(() => {
|
|
const el = editorRef.current;
|
|
if (!el) return;
|
|
el.focus();
|
|
|
|
const sel = window.getSelection();
|
|
const editorRect = el.getBoundingClientRect();
|
|
|
|
// Position popup above cursor line
|
|
let top = 0; // fallback: top of editor
|
|
let left = 0;
|
|
|
|
if (sel && sel.rangeCount > 0) {
|
|
const range = sel.getRangeAt(0);
|
|
const rect = range.getBoundingClientRect();
|
|
|
|
if (rect.height > 0) {
|
|
top = rect.top - editorRect.top - 8;
|
|
left = Math.max(0, rect.left - editorRect.left);
|
|
} else {
|
|
// Collapsed range returns zero rect — use temp marker
|
|
const marker = document.createElement('span');
|
|
marker.textContent = '\u200B';
|
|
range.insertNode(marker);
|
|
const mRect = marker.getBoundingClientRect();
|
|
top = mRect.top - editorRect.top - 8;
|
|
left = Math.max(0, mRect.left - editorRect.left);
|
|
marker.remove();
|
|
el.normalize();
|
|
}
|
|
}
|
|
|
|
setMentionPos({ top, left });
|
|
setHighlightedIdx(0);
|
|
setShowMentionPopup(true);
|
|
}, []);
|
|
|
|
const extractText = useCallback(() => {
|
|
const el = editorRef.current;
|
|
if (!el) return;
|
|
setPrompt(el.textContent || '');
|
|
setEditorHtml(el.innerHTML);
|
|
}, [setPrompt, setEditorHtml]);
|
|
|
|
// Remove orphaned mention spans when a reference is deleted
|
|
useEffect(() => {
|
|
const el = editorRef.current;
|
|
if (!el) return;
|
|
const refIds = new Set(references.map((r) => r.id));
|
|
const spans = el.querySelectorAll<HTMLElement>('[data-ref-id]');
|
|
let changed = false;
|
|
spans.forEach((span) => {
|
|
if (!refIds.has(span.dataset.refId!)) {
|
|
span.replaceWith('');
|
|
changed = true;
|
|
}
|
|
});
|
|
if (changed) {
|
|
el.normalize();
|
|
extractText();
|
|
}
|
|
}, [references, extractText]);
|
|
|
|
const handleInput = useCallback(() => {
|
|
extractText();
|
|
|
|
// Detect if user just typed @
|
|
const sel = window.getSelection();
|
|
if (!sel || sel.rangeCount === 0) return;
|
|
const range = sel.getRangeAt(0);
|
|
const node = range.startContainer;
|
|
if (node.nodeType !== Node.TEXT_NODE) return;
|
|
|
|
const text = node.textContent || '';
|
|
const offset = range.startOffset;
|
|
if (offset > 0 && text[offset - 1] === '@' && references.length > 0) {
|
|
// Keep the @ visible, open popup above it
|
|
typedAtRef.current = true;
|
|
openMentionPopup();
|
|
}
|
|
}, [extractText, references.length, openMentionPopup]);
|
|
|
|
const insertMention = useCallback((ref: UploadedFile) => {
|
|
setShowMentionPopup(false);
|
|
const el = editorRef.current;
|
|
if (!el) return;
|
|
|
|
el.focus();
|
|
const sel = window.getSelection();
|
|
if (!sel || sel.rangeCount === 0) return;
|
|
|
|
const range = sel.getRangeAt(0);
|
|
|
|
// If triggered by typing @, remove the @ character first
|
|
if (typedAtRef.current) {
|
|
typedAtRef.current = false;
|
|
const node = range.startContainer;
|
|
if (node.nodeType === Node.TEXT_NODE) {
|
|
const text = node.textContent || '';
|
|
const offset = range.startOffset;
|
|
const atIdx = text.lastIndexOf('@', offset - 1);
|
|
if (atIdx >= 0) {
|
|
node.textContent = text.substring(0, atIdx) + text.substring(offset);
|
|
range.setStart(node, atIdx);
|
|
range.collapse(true);
|
|
}
|
|
}
|
|
}
|
|
|
|
range.deleteContents();
|
|
|
|
// Create mention span
|
|
const mention = document.createElement('span');
|
|
mention.className = styles.mention;
|
|
mention.contentEditable = 'false';
|
|
mention.dataset.refId = ref.id;
|
|
mention.dataset.refType = ref.type;
|
|
mention.textContent = `@${ref.label}`;
|
|
|
|
// Insert mention + trailing space
|
|
range.insertNode(mention);
|
|
|
|
const space = document.createTextNode('\u00A0');
|
|
mention.after(space);
|
|
|
|
// Move cursor after space
|
|
const newRange = document.createRange();
|
|
newRange.setStartAfter(space);
|
|
newRange.collapse(true);
|
|
sel.removeAllRanges();
|
|
sel.addRange(newRange);
|
|
|
|
extractText();
|
|
}, [extractText]);
|
|
|
|
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
|
if (showMentionPopup) {
|
|
if (e.key === 'Escape') {
|
|
e.preventDefault();
|
|
setShowMentionPopup(false);
|
|
} else if (e.key === 'ArrowDown') {
|
|
e.preventDefault();
|
|
setHighlightedIdx((prev) => (prev + 1) % references.length);
|
|
} else if (e.key === 'ArrowUp') {
|
|
e.preventDefault();
|
|
setHighlightedIdx((prev) => (prev - 1 + references.length) % references.length);
|
|
} else if (e.key === 'Enter') {
|
|
e.preventDefault();
|
|
insertMention(references[highlightedIdx]);
|
|
}
|
|
}
|
|
}, [showMentionPopup, references, highlightedIdx, insertMention]);
|
|
|
|
const handlePaste = useCallback((e: React.ClipboardEvent) => {
|
|
e.preventDefault();
|
|
|
|
// Handle pasted image files (Ctrl+V screenshot / copied image)
|
|
const items = e.clipboardData.items;
|
|
const imageFiles: File[] = [];
|
|
for (let i = 0; i < items.length; i++) {
|
|
if (items[i].type.startsWith('image/')) {
|
|
const file = items[i].getAsFile();
|
|
if (file) imageFiles.push(file);
|
|
}
|
|
}
|
|
if (imageFiles.length > 0) {
|
|
useInputBarStore.getState().addReferences(imageFiles);
|
|
return;
|
|
}
|
|
|
|
// Plain text paste — strip @label patterns to prevent duplicate mention tags
|
|
let text = e.clipboardData.getData('text/plain');
|
|
for (const ref of references) {
|
|
const pattern = `@${ref.label}`;
|
|
while (text.includes(pattern)) {
|
|
text = text.replace(pattern, ref.label);
|
|
}
|
|
}
|
|
document.execCommand('insertText', false, text);
|
|
extractText();
|
|
}, [extractText, references]);
|
|
|
|
// Mention hover — delegated event
|
|
const handleMouseOver = useCallback((e: React.MouseEvent) => {
|
|
const target = (e.target as HTMLElement).closest('[data-ref-id]') as HTMLElement | null;
|
|
if (!target) return;
|
|
const refId = target.dataset.refId;
|
|
const ref = references.find((r) => r.id === refId);
|
|
if (!ref) return;
|
|
|
|
const rect = target.getBoundingClientRect();
|
|
const wrapperRect = editorRef.current!.parentElement!.getBoundingClientRect();
|
|
setHoverRef(ref);
|
|
setHoverPos({
|
|
top: rect.top - wrapperRect.top - 8,
|
|
left: rect.left - wrapperRect.left + rect.width / 2,
|
|
});
|
|
}, [references]);
|
|
|
|
const handleMouseOut = useCallback((e: React.MouseEvent) => {
|
|
const target = (e.target as HTMLElement).closest('[data-ref-id]');
|
|
if (target) setHoverRef(null);
|
|
}, []);
|
|
|
|
// Close mention popup on click outside
|
|
useEffect(() => {
|
|
if (!showMentionPopup) return;
|
|
const handler = (e: MouseEvent) => {
|
|
const popup = document.querySelector(`.${styles.mentionPopup}`);
|
|
if (popup && !popup.contains(e.target as Node)) {
|
|
setShowMentionPopup(false);
|
|
}
|
|
};
|
|
document.addEventListener('mousedown', handler);
|
|
return () => document.removeEventListener('mousedown', handler);
|
|
}, [showMentionPopup]);
|
|
|
|
const isEmpty = !prompt.trim() && !editorHtml;
|
|
|
|
return (
|
|
<div className={styles.wrapper}>
|
|
{isEmpty && (
|
|
<div className={styles.placeholder}>{placeholders[mode]}</div>
|
|
)}
|
|
<div
|
|
ref={editorRef}
|
|
className={styles.editor}
|
|
contentEditable
|
|
suppressContentEditableWarning
|
|
onInput={handleInput}
|
|
onKeyDown={handleKeyDown}
|
|
onPaste={handlePaste}
|
|
onMouseOver={handleMouseOver}
|
|
onMouseOut={handleMouseOut}
|
|
/>
|
|
|
|
{/* Mention popup */}
|
|
{showMentionPopup && references.length > 0 && (
|
|
<div
|
|
className={styles.mentionPopup}
|
|
style={{ top: mentionPos.top, left: mentionPos.left }}
|
|
>
|
|
<div className={styles.mentionHeader}>可能@的内容</div>
|
|
{references.map((ref, idx) => (
|
|
<button
|
|
key={`${ref.id}-${idx}`}
|
|
className={`${styles.mentionItem} ${idx === highlightedIdx ? styles.mentionItemActive : ''}`}
|
|
onMouseDown={(e) => {
|
|
e.preventDefault();
|
|
insertMention(ref);
|
|
}}
|
|
>
|
|
<div className={styles.mentionThumb}>
|
|
{ref.type === 'video' ? (
|
|
<video src={ref.previewUrl} muted className={styles.thumbMedia} />
|
|
) : (
|
|
<img src={ref.previewUrl} alt="" className={styles.thumbMedia} />
|
|
)}
|
|
</div>
|
|
<span className={styles.mentionLabel}>{ref.label}</span>
|
|
<span className={styles.mentionType}>
|
|
{ref.type === 'video' ? '视频' : '图片'}
|
|
</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Hover preview tooltip */}
|
|
{hoverRef && (
|
|
<div
|
|
className={styles.previewTooltip}
|
|
style={{ top: hoverPos.top, left: hoverPos.left }}
|
|
>
|
|
{hoverRef.type === 'video' ? (
|
|
<video
|
|
src={hoverRef.previewUrl}
|
|
autoPlay
|
|
loop
|
|
muted
|
|
playsInline
|
|
className={styles.previewMedia}
|
|
/>
|
|
) : (
|
|
<img
|
|
src={hoverRef.previewUrl}
|
|
alt={hoverRef.label}
|
|
className={styles.previewMedia}
|
|
/>
|
|
)}
|
|
<div className={styles.previewLabel}>{hoverRef.label}</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|