video-shuoshan/web/src/components/PromptInput.tsx
seaislee1209 c381784207 feat: v0.12.2 收藏功能 + UI 修复
①视频收藏(is_favorited + toggle API + 卡片/详情页收藏按钮 + 资产页「我的收藏」筛选)
②联网搜索按钮永久禁用(待开放)
③音频标签加音符符号,hover 不弹预览
④轮询完成后自动更新 token/费用(不用刷新页面)
⑤超管/团管内容资产页视频详情加上下切换箭头

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 21:36:20 +08:00

683 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useRef, useEffect, useCallback, useState } from 'react';
import DOMPurify from 'dompurify';
import { useInputBarStore } from '../store/inputBar';
import { assetsApi, tosThumb } from '../lib/api';
import type { UploadedFile, AssetGroup } 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 });
const [mentionMode, setMentionMode] = useState<'references' | 'assets'>('references');
const [assetSearchResults, setAssetSearchResults] = useState<AssetGroup[]>([]);
const searchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// 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', 'img'], ALLOWED_ATTR: ['class', 'contenteditable', 'data-ref-id', 'data-ref-type', 'data-asset-group-id', 'data-group-name', 'data-thumb-url', 'draggable', 'src', 'alt', 'width', 'height', 'style'] });
// If the HTML is plain text but we have references or asset mentions, rebuild mention spans
// This handles the case where editorHtml comes from backend (plain text only)
const currentAssetMentions = useInputBarStore.getState().assetMentions || [];
if (editorHtml && !editorHtml.includes('data-ref-id') && (references.length > 0 || currentAssetMentions.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]);
// Helper: create a mention span with optional thumbnail
const createMentionSpan = useCallback((opts: {
refId: string; refType: string; label: string; thumbUrl?: string;
assetGroupId?: string; groupName?: string;
}) => {
const span = document.createElement('span');
span.className = styles.mention;
span.contentEditable = 'false';
span.dataset.refId = opts.refId;
span.dataset.refType = opts.refType;
span.draggable = true;
if (opts.thumbUrl) span.dataset.thumbUrl = opts.thumbUrl;
if (opts.assetGroupId) span.dataset.assetGroupId = opts.assetGroupId;
if (opts.groupName) span.dataset.groupName = opts.groupName;
if (opts.refType === 'audio') {
const icon = document.createElement('span');
icon.textContent = '\u266B';
icon.style.cssText = 'margin-right:3px;font-size:13px;vertical-align:middle;pointer-events:none';
span.appendChild(icon);
} else if (opts.thumbUrl) {
const img = document.createElement('img');
img.src = tosThumb(opts.thumbUrl, 32);
img.className = styles.mentionImg;
img.setAttribute('width', '16');
img.setAttribute('height', '16');
img.style.cssText = 'width:16px;height:16px;border-radius:3px;object-fit:cover;vertical-align:middle;margin-right:3px;display:inline-block;pointer-events:none';
span.appendChild(img);
}
// @ 前缀隐藏textContent 保留用于模式匹配,视觉上不显示)
const atHidden = document.createElement('span');
atHidden.style.cssText = 'font-size:0;width:0;overflow:hidden;display:inline';
atHidden.textContent = '@';
span.appendChild(atHidden);
span.appendChild(document.createTextNode(opts.label));
return span;
}, []);
// Rebuild mention spans from plain text @label patterns
const rebuildMentionSpans = useCallback((el: HTMLElement) => {
// Collect all targets to match: references + asset mentions
const currentAssetMentions = useInputBarStore.getState().assetMentions || [];
type MatchTarget = { label: string; refId: string; refType: string; thumbUrl: string; assetGroupId?: string; groupName?: string };
const targets: MatchTarget[] = [
...references.map((ref) => ({
label: ref.label, refId: ref.id, refType: ref.type, thumbUrl: ref.previewUrl,
})),
...currentAssetMentions.map((am) => ({
label: am.label, refId: am.groupId, refType: 'asset', thumbUrl: am.thumbUrl || '',
assetGroupId: am.groupId, groupName: am.label,
})),
];
if (targets.length === 0) return;
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
const replacements: { node: Text; matches: { start: number; end: number; target: MatchTarget }[] }[] = [];
let textNode: Text | null;
while ((textNode = walker.nextNode() as Text | null)) {
const text = textNode.textContent || '';
const matches: { start: number; end: number; target: MatchTarget }[] = [];
for (const target of targets) {
const pattern = `@${target.label}`;
let idx = text.indexOf(pattern);
while (idx !== -1) {
matches.push({ start: idx, end: idx + pattern.length, target });
idx = text.indexOf(pattern, idx + pattern.length);
}
}
if (matches.length > 0) {
// Sort by position, remove overlapping matches
matches.sort((a, b) => a.start - b.start);
const filtered: typeof matches = [];
let lastEnd = 0;
for (const m of matches) {
if (m.start >= lastEnd) {
filtered.push(m);
lastEnd = m.end;
}
}
replacements.push({ node: textNode, matches: filtered });
}
}
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 = createMentionSpan({
refId: m.target.refId,
refType: m.target.refType,
label: m.target.label,
thumbUrl: m.target.thumbUrl,
assetGroupId: m.target.assetGroupId,
groupName: m.target.groupName,
});
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, createMentionSpan]);
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
// Skip asset-type spans — they are not tied to uploaded references
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 (span.dataset.refType === 'asset') return; // skip asset mentions
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;
// Find the last @ before cursor
const textBeforeCursor = text.substring(0, offset);
const lastAtIdx = textBeforeCursor.lastIndexOf('@');
if (lastAtIdx < 0) {
// No @ before cursor, close popup
setShowMentionPopup(false);
return;
}
if (lastAtIdx >= 0) {
const textAfterAt = textBeforeCursor.substring(lastAtIdx + 1);
if (textAfterAt.length === 0 && references.length > 0) {
// Just typed @, show reference popup
typedAtRef.current = true;
setMentionMode('references');
openMentionPopup();
} else if (/[\u4e00-\u9fff]+/.test(textAfterAt) && !textAfterAt.includes(' ')) {
// Chinese text after @, search assets
if (searchTimerRef.current) clearTimeout(searchTimerRef.current);
searchTimerRef.current = setTimeout(() => {
assetsApi.search(textAfterAt).then((res) => {
if (res.data.results.length > 0) {
setAssetSearchResults(res.data.results);
setMentionMode('assets');
typedAtRef.current = true;
setHighlightedIdx(0);
openMentionPopup();
} else {
setShowMentionPopup(false);
}
}).catch(() => {});
}, 300);
} else if (textAfterAt.includes(' ')) {
// Space after @ text, close popup
setShowMentionPopup(false);
}
}
}, [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 with thumbnail
const mention = createMentionSpan({
refId: ref.id,
refType: ref.type,
label: ref.label,
thumbUrl: ref.previewUrl,
});
// 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 insertAssetMention = useCallback((group: AssetGroup) => {
setShowMentionPopup(false);
setMentionMode('references');
setAssetSearchResults([]);
const el = editorRef.current;
if (!el) return;
el.focus();
const sel = window.getSelection();
if (!sel || sel.rangeCount === 0) return;
const range = sel.getRangeAt(0);
// Remove the @query text that was typed
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 for asset with thumbnail
const mention = createMentionSpan({
refId: String(group.id),
refType: 'asset',
label: group.name,
thumbUrl: group.thumbnail_url,
assetGroupId: String(group.id),
groupName: group.name,
});
range.insertNode(mention);
const space = document.createTextNode('\u00A0');
mention.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) {
const items = mentionMode === 'assets' ? assetSearchResults : references;
if (items.length === 0) return;
if (e.key === 'Escape') {
e.preventDefault();
setShowMentionPopup(false);
setMentionMode('references');
} else if (e.key === 'ArrowDown') {
e.preventDefault();
setHighlightedIdx((prev) => (prev + 1) % items.length);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setHighlightedIdx((prev) => (prev - 1 + items.length) % items.length);
} else if (e.key === 'Enter') {
e.preventDefault();
if (mentionMode === 'assets') {
insertAssetMention(assetSearchResults[highlightedIdx]);
} else {
insertMention(references[highlightedIdx]);
}
}
}
}, [showMentionPopup, mentionMode, references, assetSearchResults, highlightedIdx, insertMention, insertAssetMention]);
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;
}
// Check if clipboard HTML contains mention spans (from our editor)
const html = e.clipboardData.getData('text/html');
if (html && html.includes('data-ref-id')) {
const sanitized = DOMPurify.sanitize(html, {
ALLOWED_TAGS: ['span', 'br', 'img'],
ALLOWED_ATTR: [
'class', 'contenteditable', 'data-ref-id', 'data-ref-type',
'data-asset-group-id', 'data-group-name', 'data-thumb-url',
'draggable', 'src', 'alt', 'width', 'height', 'style',
],
});
document.execCommand('insertHTML', false, sanitized);
extractText();
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 (supports both reference and asset mentions)
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 refType = target.dataset.refType;
// 音频标签不显示 hover 预览
if (refType === 'audio') return;
// 参考图:从 references 中查找
let found = references.find((r) => r.id === refId);
// 素材库标签:用 data-thumb-url 构造预览数据
if (!found && refType === 'asset') {
const thumbUrl = target.dataset.thumbUrl;
if (thumbUrl) {
found = {
id: refId || '',
type: 'image',
previewUrl: thumbUrl,
label: target.dataset.groupName || target.textContent || '',
};
}
}
if (!found) return;
const rect = target.getBoundingClientRect();
const wrapperRect = editorRef.current!.parentElement!.getBoundingClientRect();
setHoverRef(found);
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}
onDragStart={(e) => {
const target = (e.target as HTMLElement).closest('[data-ref-id]') as HTMLElement | null;
if (target) {
e.dataTransfer.setData('text/html', target.outerHTML);
e.dataTransfer.effectAllowed = 'move';
target.classList.add(styles.dragging);
setHoverRef(null);
}
}}
onDragOver={(e) => {
e.preventDefault();
// 拖拽 mention 标签时让光标跟随鼠标位置
if (!e.dataTransfer.types.includes('Files')) {
const range = document.caretRangeFromPoint(e.clientX, e.clientY);
if (range) {
const sel = window.getSelection();
sel?.removeAllRanges();
sel?.addRange(range);
}
}
}}
onDrop={(e) => {
e.preventDefault();
const html = e.dataTransfer.getData('text/html');
if (html && html.includes('data-ref-id')) {
// 1. 先用鼠标坐标算出目标位置,插入临时 marker此时 DOM 还没变)
const dropRange = document.caretRangeFromPoint(e.clientX, e.clientY);
if (!dropRange) return;
const marker = document.createTextNode('\u200B');
dropRange.insertNode(marker);
// 2. 再删除原始标签DOM 重排不影响 marker 位置)
const dragging = editorRef.current?.querySelector(`.${styles.dragging}`);
if (dragging) dragging.remove();
// 3. 在 marker 位置插入标签
const temp = document.createElement('div');
temp.innerHTML = html;
const node = temp.firstChild;
if (node) {
marker.parentNode?.insertBefore(node, marker);
}
marker.remove();
editorRef.current?.normalize();
extractText();
}
}}
/>
{/* Mention popup */}
{showMentionPopup && (
<div
className={styles.mentionPopup}
style={{ top: mentionPos.top, left: mentionPos.left }}
>
{mentionMode === 'references' && references.length > 0 && (
<>
<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={tosThumb(ref.previewUrl, 72)} alt="" className={styles.thumbMedia} />
)}
</div>
<span className={styles.mentionLabel}>{ref.label}</span>
<span className={styles.mentionType}>
{ref.type === 'video' ? '视频' : '图片'}
</span>
</button>
))}
</>
)}
{mentionMode === 'assets' && assetSearchResults.length > 0 && (
<>
<div className={styles.mentionHeader}></div>
{assetSearchResults.map((group, idx) => (
<button
key={group.id}
className={`${styles.mentionItem} ${idx === highlightedIdx ? styles.mentionItemActive : ''}`}
onMouseDown={(e) => {
e.preventDefault();
insertAssetMention(group);
}}
>
<div className={styles.mentionThumb}>
<img src={tosThumb(group.thumbnail_url, 72)} alt="" className={styles.thumbMedia} />
</div>
<span className={styles.mentionLabel}>{group.name}</span>
<span className={styles.mentionType}></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={tosThumb(hoverRef.previewUrl, 200)}
alt={hoverRef.label}
className={styles.previewMedia}
/>
)}
<div className={styles.previewLabel}>{hoverRef.label}</div>
</div>
)}
</div>
);
}