"添加前端模板和运行代码模块"
This commit is contained in:
@@ -0,0 +1,242 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { ArrowLeft, Sparkles, Copy, Check, Loader2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import RichTextEditor from '@/components/editor/RichTextEditor';
|
||||
import { sendChatStream } from '@/services/chatStream';
|
||||
import PageMeta from '@/components/common/PageMeta';
|
||||
|
||||
const APP_ID = import.meta.env.VITE_APP_ID;
|
||||
|
||||
const markdownToHtml = (markdown: string): string => {
|
||||
let html = markdown;
|
||||
|
||||
html = html.replace(/^### (.*$)/gim, '<h3>$1</h3>');
|
||||
html = html.replace(/^## (.*$)/gim, '<h2>$1</h2>');
|
||||
html = html.replace(/^# (.*$)/gim, '<h1>$1</h1>');
|
||||
|
||||
html = html.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>');
|
||||
html = html.replace(/\*(.*?)\*/g, '<em>$1</em>');
|
||||
|
||||
html = html.replace(/^\* (.*$)/gim, '<li>$1</li>');
|
||||
html = html.replace(/(<li>.*<\/li>)/s, '<ul>$1</ul>');
|
||||
|
||||
html = html.replace(/\n\n/g, '</p><p>');
|
||||
html = '<p>' + html + '</p>';
|
||||
|
||||
return html;
|
||||
};
|
||||
|
||||
export default function ArticleGenerator() {
|
||||
const navigate = useNavigate();
|
||||
const [topic, setTopic] = useState('');
|
||||
const [content, setContent] = useState('');
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
const [abortController, setAbortController] = useState<AbortController | null>(null);
|
||||
|
||||
const handleGenerate = async () => {
|
||||
if (!topic.trim()) {
|
||||
toast.error('请输入文章主题');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsGenerating(true);
|
||||
setContent('');
|
||||
const controller = new AbortController();
|
||||
setAbortController(controller);
|
||||
|
||||
let markdownContent = '';
|
||||
|
||||
try {
|
||||
await sendChatStream({
|
||||
endpoint: 'https://api-integrations.appmiaoda.com/app-7a7nlc9zki69/api-2bk93oeO9NlE/v2/chat/completions',
|
||||
apiId: APP_ID,
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: '你是一个专业的公众号文章写手,擅长创作吸引人的图文内容。请根据用户提供的主题,生成一篇完整的公众号文章,包括标题、引言、正文和结尾。文章要有吸引力,语言生动,适合微信公众号阅读。使用 Markdown 格式输出。'
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: `请为我创作一篇关于"${topic}"的公众号文章`
|
||||
}
|
||||
],
|
||||
onUpdate: (newContent: string) => {
|
||||
markdownContent = newContent;
|
||||
const htmlContent = markdownToHtml(markdownContent);
|
||||
setContent(htmlContent);
|
||||
},
|
||||
onComplete: () => {
|
||||
setIsGenerating(false);
|
||||
setAbortController(null);
|
||||
toast.success('文章生成完成');
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
setIsGenerating(false);
|
||||
setAbortController(null);
|
||||
toast.error(`生成失败: ${error.message}`);
|
||||
},
|
||||
signal: controller.signal
|
||||
});
|
||||
} catch (error) {
|
||||
if (!controller.signal.aborted) {
|
||||
setIsGenerating(false);
|
||||
setAbortController(null);
|
||||
toast.error('生成失败,请重试');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleStop = () => {
|
||||
if (abortController) {
|
||||
abortController.abort();
|
||||
setAbortController(null);
|
||||
setIsGenerating(false);
|
||||
toast.info('已停止生成');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
const tempDiv = document.createElement('div');
|
||||
tempDiv.innerHTML = content;
|
||||
|
||||
const blob = new Blob([tempDiv.innerHTML], { type: 'text/html' });
|
||||
const clipboardItem = new ClipboardItem({
|
||||
'text/html': blob,
|
||||
'text/plain': new Blob([tempDiv.innerText], { type: 'text/plain' })
|
||||
});
|
||||
|
||||
await navigator.clipboard.write([clipboardItem]);
|
||||
setIsCopied(true);
|
||||
toast.success('内容已复制,可直接粘贴到公众号编辑器');
|
||||
|
||||
setTimeout(() => setIsCopied(false), 2000);
|
||||
} catch (error) {
|
||||
toast.error('复制失败,请手动选择复制');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageMeta title="图文生成 - 公众号推文助手" description="输入文章主题,AI 将为您生成完整的公众号图文内容" />
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="mb-6"
|
||||
onClick={() => navigate('/')}
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
返回首页
|
||||
</Button>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Sparkles className="h-5 w-5 text-primary" />
|
||||
图文生成
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
输入文章主题,AI 将为您生成完整的公众号图文内容
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="topic">文章主题</Label>
|
||||
<Textarea
|
||||
id="topic"
|
||||
placeholder="例如:如何提高工作效率、健康饮食的重要性、春季旅游攻略..."
|
||||
value={topic}
|
||||
onChange={(e) => setTopic(e.target.value)}
|
||||
rows={4}
|
||||
disabled={isGenerating}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isGenerating ? (
|
||||
<Button
|
||||
className="w-full"
|
||||
variant="destructive"
|
||||
onClick={handleStop}
|
||||
>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
停止生成
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={handleGenerate}
|
||||
disabled={!topic.trim()}
|
||||
>
|
||||
<Sparkles className="mr-2 h-4 w-4" />
|
||||
生成文章
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{content && !isGenerating && (
|
||||
<Button
|
||||
className="w-full"
|
||||
variant="outline"
|
||||
onClick={handleCopy}
|
||||
>
|
||||
{isCopied ? (
|
||||
<>
|
||||
<Check className="mr-2 h-4 w-4" />
|
||||
已复制
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
复制内容
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<div className="text-sm text-muted-foreground space-y-2 pt-4 border-t">
|
||||
<p className="font-semibold">使用提示:</p>
|
||||
<ul className="list-disc list-inside space-y-1">
|
||||
<li>主题描述越详细,生成的内容越精准</li>
|
||||
<li>可以在编辑器中修改生成的内容</li>
|
||||
<li>点击"复制内容"后可直接粘贴到公众号</li>
|
||||
</ul>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>内容预览与编辑</CardTitle>
|
||||
<CardDescription>
|
||||
在这里查看和编辑生成的内容
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{content ? (
|
||||
<RichTextEditor
|
||||
content={content}
|
||||
onChange={setContent}
|
||||
/>
|
||||
) : (
|
||||
<div className="border rounded-lg p-8 text-center text-muted-foreground min-h-[400px] flex items-center justify-center">
|
||||
<div>
|
||||
<Sparkles className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
||||
<p>输入主题并点击生成,内容将在这里显示</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { FileText, Image, Video, Code2 } from 'lucide-react';
|
||||
import PageMeta from '@/components/common/PageMeta';
|
||||
|
||||
export default function Home() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const features = [
|
||||
{
|
||||
icon: FileText,
|
||||
title: '图文生成',
|
||||
description: '输入主题,AI 自动生成完整的图文推送内容',
|
||||
path: '/article-generator',
|
||||
color: 'text-primary'
|
||||
},
|
||||
{
|
||||
icon: Image,
|
||||
title: '图片配文',
|
||||
description: '上传图片,AI 为您生成精准的文案内容',
|
||||
path: '/image-caption',
|
||||
color: 'text-primary'
|
||||
},
|
||||
{
|
||||
icon: Video,
|
||||
title: '视频脚本',
|
||||
description: '创建视频拍摄和制作的详细脚本文案',
|
||||
path: '/video-script',
|
||||
color: 'text-primary'
|
||||
},
|
||||
{
|
||||
icon: Code2,
|
||||
title: '设计模式演示',
|
||||
description: '体验 Iframe 设计模式,实时编辑页面元素',
|
||||
path: '/iframe-demo',
|
||||
color: 'text-primary'
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageMeta title="公众号推文助手" description="专为微信公众号内容创作者设计的一站式推文制作工具" />
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="container mx-auto px-4 py-12">
|
||||
<div className="text-center mb-12">
|
||||
<h1 className="text-4xl font-bold text-foreground mb-4">
|
||||
公众号推文助手
|
||||
</h1>
|
||||
<p className="text-lg text-muted-foreground max-w-2xl mx-auto">
|
||||
专为微信公众号内容创作者设计,提供图文生成、图片配文、视频脚本创作等功能
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 max-w-6xl mx-auto">
|
||||
{features.map((feature) => {
|
||||
const Icon = feature.icon;
|
||||
return (
|
||||
<Card
|
||||
key={feature.path}
|
||||
className="hover:shadow-lg transition-all duration-300 cursor-pointer border-2 hover:border-primary"
|
||||
onClick={() => navigate(feature.path)}
|
||||
>
|
||||
<CardHeader>
|
||||
<div className="w-12 h-12 rounded-lg bg-accent flex items-center justify-center mb-4">
|
||||
<Icon className={`w-6 h-6 ${feature.color}`} />
|
||||
</div>
|
||||
<CardTitle className="text-xl">{feature.title}</CardTitle>
|
||||
<CardDescription className="text-base">
|
||||
{feature.description}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button className="w-full" variant="default">
|
||||
开始创作
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="mt-16 text-center">
|
||||
<Card className="max-w-3xl mx-auto bg-accent border-primary">
|
||||
<CardHeader>
|
||||
<CardTitle className="font-semibold text-2xl text-blue-600">功能特色</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 text-left">
|
||||
<div>
|
||||
<h3 className="font-semibold text-foreground mb-2">⚡ 快速生成</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
AI 驱动,秒级生成高质量内容
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-foreground mb-2">✨ 在线编辑</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
富文本编辑器,支持样式自定义
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-foreground mb-2">📋 一键复制</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
保留格式,直接粘贴到公众号
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,567 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import type { ElementInfo } from '@/types/messages';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import PageMeta from '@/components/common/PageMeta';
|
||||
|
||||
export default function IframeDemoPage() {
|
||||
const navigate = useNavigate();
|
||||
const [iframeDesignMode, setIframeDesignMode] = useState(false);
|
||||
const [selectedElement, setSelectedElement] = useState<ElementInfo | null>(
|
||||
null
|
||||
);
|
||||
const [editingContent, setEditingContent] = useState('');
|
||||
const [editingClass, setEditingClass] = useState('');
|
||||
const [pendingChanges, setPendingChanges] = useState<
|
||||
Array<{
|
||||
type: 'style' | 'content';
|
||||
sourceInfo: any;
|
||||
newValue: string;
|
||||
originalValue?: string;
|
||||
}>
|
||||
>([]);
|
||||
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
|
||||
// Listen for messages from iframe
|
||||
useEffect(() => {
|
||||
const handleMessage = (event: MessageEvent) => {
|
||||
// Debug log for message source
|
||||
if (event.data.type === 'ELEMENT_SELECTED') {
|
||||
console.log('[Parent] Received ELEMENT_SELECTED', {
|
||||
source: event.source,
|
||||
iframeWindow: iframeRef.current?.contentWindow,
|
||||
isMatch: iframeRef.current && event.source === iframeRef.current.contentWindow,
|
||||
data: event.data
|
||||
});
|
||||
}
|
||||
if (event.data.type === 'ADD_TO_CHAT') {
|
||||
return console.log('[Parent] Add to chat:', event.data.payload);
|
||||
}
|
||||
|
||||
if (event.data.type === 'COPY_ELEMENT') {
|
||||
return console.log('[Parent] Copy element:', event.data.payload);
|
||||
}
|
||||
|
||||
// Only accept messages from the iframe
|
||||
if (iframeRef.current && event.source !== iframeRef.current.contentWindow) {
|
||||
if (event.data.type === 'ELEMENT_SELECTED') {
|
||||
console.warn('[Parent] Ignoring message from non-iframe source');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const { type, payload } = event.data;
|
||||
|
||||
switch (type) {
|
||||
case 'DESIGN_MODE_CHANGED':
|
||||
setIframeDesignMode(event.data.enabled);
|
||||
break;
|
||||
|
||||
case 'ELEMENT_SELECTED':
|
||||
console.log('[Parent] Processing ELEMENT_SELECTED', payload);
|
||||
|
||||
// 验证 sourceInfo 是否有效
|
||||
if (
|
||||
!payload.elementInfo?.sourceInfo ||
|
||||
!payload.elementInfo.sourceInfo.fileName ||
|
||||
payload.elementInfo.sourceInfo.lineNumber === 0
|
||||
) {
|
||||
console.warn(
|
||||
'[Parent] Invalid sourceInfo received:',
|
||||
payload.elementInfo?.sourceInfo
|
||||
);
|
||||
console.warn('[Parent] This may cause update operations to fail');
|
||||
}
|
||||
|
||||
setSelectedElement(payload.elementInfo);
|
||||
setEditingContent(payload.elementInfo.textContent);
|
||||
setEditingClass(payload.elementInfo.className);
|
||||
break;
|
||||
|
||||
case 'ELEMENT_DESELECTED':
|
||||
setSelectedElement(null);
|
||||
console.log('[Parent] Element deselected');
|
||||
break;
|
||||
|
||||
case 'CONTENT_UPDATED':
|
||||
console.log('[Parent] Content updated:', payload);
|
||||
break;
|
||||
|
||||
case 'STYLE_UPDATED':
|
||||
console.log('[Parent] Style updated:', payload);
|
||||
break;
|
||||
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('message', handleMessage);
|
||||
return () => window.removeEventListener('message', handleMessage);
|
||||
}, []);
|
||||
|
||||
// Debounce hook
|
||||
const useDebounce = <T,>(value: T, delay: number): T => {
|
||||
const [debouncedValue, setDebouncedValue] = useState(value);
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => {
|
||||
setDebouncedValue(value);
|
||||
}, delay);
|
||||
return () => clearTimeout(handler);
|
||||
}, [value, delay]);
|
||||
return debouncedValue;
|
||||
};
|
||||
|
||||
const debouncedContent = useDebounce(editingContent, 300);
|
||||
const debouncedClass = useDebounce(editingClass, 300);
|
||||
|
||||
// Upsert pending change
|
||||
const upsertPendingChange = (
|
||||
type: 'style' | 'content',
|
||||
newValue: string,
|
||||
originalValue?: string
|
||||
) => {
|
||||
if (!selectedElement) return;
|
||||
setPendingChanges(prev => {
|
||||
const existingIndex = prev.findIndex(
|
||||
item =>
|
||||
item.type === type &&
|
||||
item.sourceInfo.fileName === selectedElement.sourceInfo.fileName &&
|
||||
item.sourceInfo.lineNumber === selectedElement.sourceInfo.lineNumber
|
||||
);
|
||||
|
||||
const newChange = {
|
||||
type,
|
||||
sourceInfo: selectedElement.sourceInfo,
|
||||
newValue,
|
||||
originalValue: originalValue || (type === 'style' ? selectedElement.className : selectedElement.textContent),
|
||||
};
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
const newList = [...prev];
|
||||
newList[existingIndex] = newChange;
|
||||
return newList;
|
||||
} else {
|
||||
return [...prev, newChange];
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const lastSelectedElementRef = useRef(selectedElement);
|
||||
|
||||
// Real-time content update
|
||||
useEffect(() => {
|
||||
if (!selectedElement) return;
|
||||
|
||||
// If selection changed, skip this update cycle
|
||||
if (selectedElement !== lastSelectedElementRef.current) {
|
||||
console.log('[Parent] Skipping content update due to selection change');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[Parent] Content effect triggered', {
|
||||
debouncedContent,
|
||||
currentContent: selectedElement.textContent,
|
||||
shouldUpdate: debouncedContent !== selectedElement.textContent
|
||||
});
|
||||
|
||||
if (debouncedContent === selectedElement.textContent) return;
|
||||
|
||||
console.log('[Parent] Sending UPDATE_CONTENT', debouncedContent);
|
||||
upsertPendingChange('content', debouncedContent);
|
||||
|
||||
const iframe = iframeRef.current;
|
||||
if (iframe && iframe.contentWindow) {
|
||||
iframe.contentWindow.postMessage(
|
||||
{
|
||||
type: 'UPDATE_CONTENT',
|
||||
payload: {
|
||||
sourceInfo: selectedElement.sourceInfo,
|
||||
newContent: debouncedContent,
|
||||
persist: false,
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
'*'
|
||||
);
|
||||
}
|
||||
}, [debouncedContent, selectedElement]); // Removed upsertPendingChange to fix infinite loop
|
||||
|
||||
// Real-time style update
|
||||
useEffect(() => {
|
||||
if (!selectedElement) return;
|
||||
|
||||
// If selection changed, skip this update cycle
|
||||
if (selectedElement !== lastSelectedElementRef.current) {
|
||||
console.log('[Parent] Skipping style update due to selection change');
|
||||
return;
|
||||
}
|
||||
|
||||
if (debouncedClass === selectedElement.className) return;
|
||||
|
||||
upsertPendingChange('style', debouncedClass);
|
||||
|
||||
const iframe = iframeRef.current;
|
||||
if (iframe && iframe.contentWindow) {
|
||||
iframe.contentWindow.postMessage(
|
||||
{
|
||||
type: 'UPDATE_STYLE',
|
||||
payload: {
|
||||
sourceInfo: selectedElement.sourceInfo,
|
||||
newClass: debouncedClass,
|
||||
persist: false,
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
'*'
|
||||
);
|
||||
}
|
||||
}, [debouncedClass, selectedElement]); // Removed upsertPendingChange to fix infinite loop
|
||||
|
||||
// Update lastSelectedElementRef AFTER other effects
|
||||
useEffect(() => {
|
||||
lastSelectedElementRef.current = selectedElement;
|
||||
}, [selectedElement]);
|
||||
|
||||
// Style Manager Logic
|
||||
const toggleStyle = (newStyle: string, categoryRegex: RegExp) => {
|
||||
let currentClasses = editingClass.split(' ').filter(c => c.trim());
|
||||
|
||||
// Remove existing class in the same category
|
||||
currentClasses = currentClasses.filter(c => !categoryRegex.test(c));
|
||||
|
||||
// Add new style if it's not empty (allows clearing style)
|
||||
if (newStyle) {
|
||||
currentClasses.push(newStyle);
|
||||
}
|
||||
|
||||
setEditingClass(currentClasses.join(' '));
|
||||
};
|
||||
|
||||
const hasStyle = (style: string) => {
|
||||
return editingClass.split(' ').includes(style);
|
||||
};
|
||||
|
||||
// UI Controls
|
||||
const renderColorPicker = (prefix: string, colors: string[], label: string) => (
|
||||
<div className="mb-4">
|
||||
<Label className="mb-2">{label}</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{colors.map(color => {
|
||||
const styleClass = `${prefix}-${color}`;
|
||||
const isActive = hasStyle(styleClass);
|
||||
return (
|
||||
<button
|
||||
key={color}
|
||||
onClick={() => toggleStyle(styleClass, new RegExp(`^${prefix}-[a-z]+(-\\d+)?$`))}
|
||||
className={`w-8 h-8 rounded-full border-2 transition-all ${isActive ? 'border-gray-900 scale-110' : 'border-transparent hover:scale-105'
|
||||
} ${styleClass.replace('text-', 'bg-')}`} // Use bg color for preview
|
||||
title={color}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<button
|
||||
onClick={() => toggleStyle('', new RegExp(`^${prefix}-[a-z]+(-\\d+)?$`))}
|
||||
className="px-2 py-1 text-xs text-gray-500 border border-gray-300 rounded hover:bg-gray-100"
|
||||
>
|
||||
清除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderSelect = (prefix: string, options: { label: string; value: string }[], label: string) => (
|
||||
<div className="mb-4">
|
||||
<Label className="mb-2">{label}</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{options.map(opt => {
|
||||
const styleClass = opt.value ? `${prefix}-${opt.value}` : '';
|
||||
const isActive = styleClass ? hasStyle(styleClass) : !options.some(o => o.value && hasStyle(`${prefix}-${o.value}`));
|
||||
return (
|
||||
<button
|
||||
key={opt.label}
|
||||
onClick={() => toggleStyle(styleClass, new RegExp(`^${prefix}(-[a-z0-9]+)?$`))}
|
||||
className={`px-3 py-1 text-sm rounded border transition-all ${isActive
|
||||
? 'bg-blue-600 text-white border-blue-600'
|
||||
: 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Toggle design mode in iframe
|
||||
const toggleIframeDesignMode = () => {
|
||||
const iframe = iframeRef.current;
|
||||
console.log('[Parent] Toggling iframe design mode...', iframe);
|
||||
if (iframe && iframe.contentWindow) {
|
||||
iframe.contentWindow.postMessage(
|
||||
{
|
||||
type: 'TOGGLE_DESIGN_MODE',
|
||||
enabled: !iframeDesignMode,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
'*'
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// 保存所有更改
|
||||
const saveChanges = async () => {
|
||||
if (pendingChanges.length === 0) return;
|
||||
|
||||
console.log('[Parent] Saving changes...', pendingChanges);
|
||||
|
||||
try {
|
||||
const response = await fetch('/__appdev_design_mode/batch-update', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
updates: pendingChanges.map(change => ({
|
||||
filePath: change.sourceInfo.fileName,
|
||||
line: change.sourceInfo.lineNumber,
|
||||
column: change.sourceInfo.columnNumber,
|
||||
newValue: change.newValue,
|
||||
type: change.type,
|
||||
originalValue: change.originalValue,
|
||||
})),
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
console.log('[Parent] Batch update success:', result);
|
||||
alert(`成功保存 ${result.summary.successful} 个更改!`);
|
||||
setPendingChanges([]); // 清空待保存列表
|
||||
} else {
|
||||
const error = await response.json();
|
||||
console.error('[Parent] Batch update failed:', error);
|
||||
alert('保存失败,请查看控制台错误信息。');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Parent] Error saving changes:', error);
|
||||
alert('保存出错,请检查网络连接。');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageMeta title="Iframe 设计模式演示" description="设计模式 Iframe 演示页面" />
|
||||
<div className='min-h-screen bg-gray-100' data-selection-exclude="true">
|
||||
{/* Top Control Bar */}
|
||||
<div className='bg-white border-b border-gray-200 px-6 py-4'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center gap-4'>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => navigate(-1)}
|
||||
className="mr-2"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<h1 className='text-2xl font-bold text-gray-900'>
|
||||
Iframe 设计模式演示
|
||||
</h1>
|
||||
</div>
|
||||
<div className='flex gap-4'>
|
||||
{pendingChanges.length > 0 && (
|
||||
<Button
|
||||
onClick={saveChanges}
|
||||
className='bg-blue-600 hover:bg-blue-700'
|
||||
>
|
||||
<span>保存更改 ({pendingChanges.length})</span>
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
onClick={toggleIframeDesignMode}
|
||||
variant={iframeDesignMode ? "default" : "outline"}
|
||||
className={iframeDesignMode ? 'bg-green-500 hover:bg-green-600' : ''}
|
||||
>
|
||||
{iframeDesignMode ? '✓ 设计模式已启用' : '启用设计模式'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex h-[calc(100vh-73px)]'>
|
||||
{/* Left: External Property Panel */}
|
||||
<div className='w-80 bg-white border-r border-gray-200 overflow-y-auto'>
|
||||
<div className='p-6'>
|
||||
<h2 className='text-lg font-bold text-gray-900 mb-4'>
|
||||
外部属性面板
|
||||
</h2>
|
||||
|
||||
{selectedElement ? (
|
||||
<div className='space-y-6'>
|
||||
{/* Element Info */}
|
||||
<div className='bg-blue-50 border border-blue-200 rounded-lg p-4'>
|
||||
<h3 className='text-sm font-semibold text-blue-900 mb-2'>
|
||||
选中元素
|
||||
</h3>
|
||||
<div className='text-xs text-blue-700 space-y-2'>
|
||||
{/* 基本信息 */}
|
||||
<div className="bg-blue-50 p-2 rounded">
|
||||
<div className="font-semibold mb-1">基本信息</div>
|
||||
<div><span className='font-medium'>标签:</span> {selectedElement.tagName}</div>
|
||||
{selectedElement.sourceInfo.elementType && selectedElement.sourceInfo.elementType !== selectedElement.tagName && (
|
||||
<div><span className='font-medium'>组件类型:</span> {selectedElement.sourceInfo.elementType}</div>
|
||||
)}
|
||||
{selectedElement.componentName && (
|
||||
<div><span className='font-medium'>所属组件:</span> {selectedElement.componentName}</div>
|
||||
)}
|
||||
{selectedElement.sourceInfo.importPath && (
|
||||
<div className='break-all'>
|
||||
<span className='font-medium'>组件定义:</span>{' '}
|
||||
<span className="text-gray-600">{selectedElement.sourceInfo.importPath}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className='break-all'>
|
||||
<span className='font-medium'>使用位置:</span>{' '}
|
||||
{selectedElement.sourceInfo.fileName.split('/').pop()}
|
||||
</div>
|
||||
<div className='text-gray-500 text-[10px] break-all'>
|
||||
{selectedElement.sourceInfo.fileName}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 属性列表 */}
|
||||
{selectedElement.props && Object.keys(selectedElement.props).length > 0 && (
|
||||
<div className="bg-blue-50 p-2 rounded">
|
||||
<div className="font-semibold mb-1">属性 (Props)</div>
|
||||
<div className="space-y-1 max-h-32 overflow-y-auto">
|
||||
{Object.entries(selectedElement.props).map(([key, value]) => (
|
||||
<div key={key} className="flex gap-1">
|
||||
<span className="font-medium text-blue-800">{key}:</span>
|
||||
<span className="truncate text-gray-600" title={value as string}>
|
||||
{value as string}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 组件层级 */}
|
||||
{selectedElement.hierarchy && selectedElement.hierarchy.length > 0 && (
|
||||
<div className="bg-blue-50 p-2 rounded">
|
||||
<div className="font-semibold mb-1">组件层级</div>
|
||||
<div className="space-y-1 max-h-32 overflow-y-auto">
|
||||
{selectedElement.hierarchy.map((item, index) => (
|
||||
<div key={index} className="pl-2 border-l-2 border-blue-200 text-[10px]">
|
||||
{item.componentName ? (
|
||||
<span className="font-bold text-blue-800">{item.componentName}</span>
|
||||
) : (
|
||||
<span className="text-gray-500">{item.tagName}</span>
|
||||
)}
|
||||
{item.fileName && (
|
||||
<span className="ml-1 text-gray-400">
|
||||
({item.fileName.split('/').pop()})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content Editor - 仅当 isStaticText 为 true 时显示 */}
|
||||
{selectedElement.isStaticText && (
|
||||
<div>
|
||||
<Label className='mb-2'>
|
||||
内容编辑
|
||||
</Label>
|
||||
<Textarea
|
||||
value={editingContent}
|
||||
onChange={e => setEditingContent(e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<hr className="border-gray-200" />
|
||||
|
||||
{/* Smart Style Controls */}
|
||||
<div>
|
||||
<h3 className="text-md font-bold text-gray-900 mb-4">样式设置</h3>
|
||||
|
||||
{renderColorPicker('bg', ['white', 'gray-100', 'red-100', 'blue-100', 'green-100', 'yellow-100', 'purple-100'], '背景颜色')}
|
||||
{renderColorPicker('text', ['gray-900', 'gray-500', 'red-600', 'blue-600', 'green-600', 'purple-600', 'white'], '文字颜色')}
|
||||
|
||||
{renderSelect('p', [
|
||||
{ label: '无', value: '0' },
|
||||
{ label: '小', value: '2' },
|
||||
{ label: '中', value: '4' },
|
||||
{ label: '大', value: '8' },
|
||||
{ label: '特大', value: '12' }
|
||||
], '内边距 (Padding)')}
|
||||
|
||||
{renderSelect('rounded', [
|
||||
{ label: '直角', value: 'none' },
|
||||
{ label: '小圆角', value: 'sm' },
|
||||
{ label: '圆角', value: 'md' },
|
||||
{ label: '大圆角', value: 'lg' },
|
||||
{ label: '全圆角', value: 'full' }
|
||||
], '圆角 (Border Radius)')}
|
||||
|
||||
{renderSelect('text', [
|
||||
{ label: '小', value: 'sm' },
|
||||
{ label: '默认', value: 'base' },
|
||||
{ label: '中', value: 'lg' },
|
||||
{ label: '大', value: 'xl' },
|
||||
{ label: '特大', value: '2xl' }
|
||||
], '文字大小 (Font Size)')}
|
||||
</div>
|
||||
|
||||
{/* Raw Style Editor */}
|
||||
<div className="mt-6 pt-6 border-t border-gray-200">
|
||||
<Label className='mb-2 text-gray-500'>
|
||||
原始 ClassName (高级)
|
||||
</Label>
|
||||
<Textarea
|
||||
value={editingClass}
|
||||
onChange={e => setEditingClass(e.target.value)}
|
||||
className='font-mono text-xs'
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className='text-center py-12 text-gray-500'>
|
||||
<p className='text-sm'>
|
||||
{iframeDesignMode
|
||||
? '点击 iframe 内的元素开始编辑'
|
||||
: '请先启用设计模式'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Iframe Preview */}
|
||||
<div className='flex-1 bg-gray-50 p-6'>
|
||||
<div className='h-full bg-white rounded-lg shadow-xl overflow-hidden'>
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
src={'http://localhost:3001/'}
|
||||
className='w-full h-full'
|
||||
title='设计模式 Iframe 演示'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { ArrowLeft, Sparkles, Copy, Check, Loader2, Upload, X } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import RichTextEditor from '@/components/editor/RichTextEditor';
|
||||
import { sendChatStream } from '@/services/chatStream';
|
||||
import PageMeta from '@/components/common/PageMeta';
|
||||
|
||||
const APP_ID = import.meta.env.VITE_APP_ID;
|
||||
|
||||
const markdownToHtml = (markdown: string): string => {
|
||||
let html = markdown;
|
||||
|
||||
html = html.replace(/^### (.*$)/gim, '<h3>$1</h3>');
|
||||
html = html.replace(/^## (.*$)/gim, '<h2>$1</h2>');
|
||||
html = html.replace(/^# (.*$)/gim, '<h1>$1</h1>');
|
||||
|
||||
html = html.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>');
|
||||
html = html.replace(/\*(.*?)\*/g, '<em>$1</em>');
|
||||
|
||||
html = html.replace(/^\* (.*$)/gim, '<li>$1</li>');
|
||||
html = html.replace(/(<li>.*<\/li>)/s, '<ul>$1</ul>');
|
||||
|
||||
html = html.replace(/\n\n/g, '</p><p>');
|
||||
html = '<p>' + html + '</p>';
|
||||
|
||||
return html;
|
||||
};
|
||||
|
||||
export default function ImageCaption() {
|
||||
const navigate = useNavigate();
|
||||
const [imageFile, setImageFile] = useState<File | null>(null);
|
||||
const [imagePreview, setImagePreview] = useState<string>('');
|
||||
const [imageBase64, setImageBase64] = useState<string>('');
|
||||
const [content, setContent] = useState('');
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
const [abortController, setAbortController] = useState<AbortController | null>(null);
|
||||
|
||||
const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
if (!file.type.match(/^image\/(jpeg|jpg|png|bmp)$/)) {
|
||||
toast.error('仅支持 JPG、PNG、BMP 格式的图片');
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
toast.error('图片大小不能超过 10MB');
|
||||
return;
|
||||
}
|
||||
|
||||
setImageFile(file);
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = (event) => {
|
||||
const result = event.target?.result as string;
|
||||
setImagePreview(result);
|
||||
|
||||
const base64Data = result.split(',')[1];
|
||||
const mimeType = file.type;
|
||||
setImageBase64(`data:${mimeType};base64,${base64Data}`);
|
||||
};
|
||||
|
||||
reader.readAsDataURL(file);
|
||||
};
|
||||
|
||||
const handleRemoveImage = () => {
|
||||
setImageFile(null);
|
||||
setImagePreview('');
|
||||
setImageBase64('');
|
||||
setContent('');
|
||||
};
|
||||
|
||||
const handleGenerate = async () => {
|
||||
if (!imageBase64) {
|
||||
toast.error('请先上传图片');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsGenerating(true);
|
||||
setContent('');
|
||||
const controller = new AbortController();
|
||||
setAbortController(controller);
|
||||
|
||||
let markdownContent = '';
|
||||
|
||||
try {
|
||||
await sendChatStream({
|
||||
endpoint: 'https://api-integrations.appmiaoda.com/app-7a7nlc9zki69/api-2jBYdN3A9Jyz/v2/chat/completions',
|
||||
apiId: APP_ID,
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: '你是一个专业的公众号文案写手,擅长为图片配写简洁有力的文案。请根据图片内容,生成适合公众号发布的配文,包括标题和正文。文案要简洁、有吸引力,能够引起读者共鸣。使用 Markdown 格式输出。'
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: '请为这张图片生成适合公众号发布的配文'
|
||||
},
|
||||
{
|
||||
type: 'image_url',
|
||||
image_url: {
|
||||
url: imageBase64
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
onUpdate: (newContent: string) => {
|
||||
markdownContent = newContent;
|
||||
const htmlContent = markdownToHtml(markdownContent);
|
||||
setContent(htmlContent);
|
||||
},
|
||||
onComplete: () => {
|
||||
setIsGenerating(false);
|
||||
setAbortController(null);
|
||||
toast.success('配文生成完成');
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
setIsGenerating(false);
|
||||
setAbortController(null);
|
||||
toast.error(`生成失败: ${error.message}`);
|
||||
},
|
||||
signal: controller.signal
|
||||
});
|
||||
} catch (error) {
|
||||
if (!controller.signal.aborted) {
|
||||
setIsGenerating(false);
|
||||
setAbortController(null);
|
||||
toast.error('生成失败,请重试');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleStop = () => {
|
||||
if (abortController) {
|
||||
abortController.abort();
|
||||
setAbortController(null);
|
||||
setIsGenerating(false);
|
||||
toast.info('已停止生成');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
const tempDiv = document.createElement('div');
|
||||
tempDiv.innerHTML = content;
|
||||
|
||||
const blob = new Blob([tempDiv.innerHTML], { type: 'text/html' });
|
||||
const clipboardItem = new ClipboardItem({
|
||||
'text/html': blob,
|
||||
'text/plain': new Blob([tempDiv.innerText], { type: 'text/plain' })
|
||||
});
|
||||
|
||||
await navigator.clipboard.write([clipboardItem]);
|
||||
setIsCopied(true);
|
||||
toast.success('内容已复制,可直接粘贴到公众号编辑器');
|
||||
|
||||
setTimeout(() => setIsCopied(false), 2000);
|
||||
} catch (error) {
|
||||
toast.error('复制失败,请手动选择复制');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageMeta title="图片配文 - 公众号推文助手" description="上传图片,AI 将为您生成精准的文案内容" />
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="mb-6"
|
||||
onClick={() => navigate('/')}
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
返回首页
|
||||
</Button>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Sparkles className="h-5 w-5 text-primary" />
|
||||
图片配文
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
上传图片,AI 将为您生成精准的文案内容
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>上传图片</Label>
|
||||
{imagePreview ? (
|
||||
<div className="relative">
|
||||
<img
|
||||
src={imagePreview}
|
||||
alt="预览"
|
||||
className="w-full rounded-lg border"
|
||||
crossOrigin="anonymous"
|
||||
/>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
className="absolute top-2 right-2"
|
||||
onClick={handleRemoveImage}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<label className="flex flex-col items-center justify-center w-full h-64 border-2 border-dashed rounded-lg cursor-pointer hover:bg-accent transition-colors">
|
||||
<div className="flex flex-col items-center justify-center pt-5 pb-6">
|
||||
<Upload className="w-10 h-10 mb-3 text-muted-foreground" />
|
||||
<p className="mb-2 text-sm text-muted-foreground">
|
||||
<span className="font-semibold">点击上传</span> 或拖拽图片到此处
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
支持 JPG、PNG、BMP 格式,最大 10MB
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
type="file"
|
||||
className="hidden"
|
||||
accept="image/jpeg,image/jpg,image/png,image/bmp"
|
||||
onChange={handleImageUpload}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isGenerating ? (
|
||||
<Button
|
||||
className="w-full"
|
||||
variant="destructive"
|
||||
onClick={handleStop}
|
||||
>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
停止生成
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={handleGenerate}
|
||||
disabled={!imageBase64}
|
||||
>
|
||||
<Sparkles className="mr-2 h-4 w-4" />
|
||||
生成配文
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{content && !isGenerating && (
|
||||
<Button
|
||||
className="w-full"
|
||||
variant="outline"
|
||||
onClick={handleCopy}
|
||||
>
|
||||
{isCopied ? (
|
||||
<>
|
||||
<Check className="mr-2 h-4 w-4" />
|
||||
已复制
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
复制内容
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<div className="text-sm text-muted-foreground space-y-2 pt-4 border-t">
|
||||
<p className="font-semibold">使用提示:</p>
|
||||
<ul className="list-disc list-inside space-y-1">
|
||||
<li>图片内容越清晰,生成的配文越精准</li>
|
||||
<li>支持 JPG、PNG、BMP 格式</li>
|
||||
<li>可以在编辑器中修改生成的配文</li>
|
||||
</ul>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>配文预览与编辑</CardTitle>
|
||||
<CardDescription>
|
||||
在这里查看和编辑生成的配文
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{content ? (
|
||||
<RichTextEditor
|
||||
content={content}
|
||||
onChange={setContent}
|
||||
/>
|
||||
) : (
|
||||
<div className="border rounded-lg p-8 text-center text-muted-foreground min-h-[400px] flex items-center justify-center">
|
||||
<div>
|
||||
<Sparkles className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
||||
<p>上传图片并点击生成,配文将在这里显示</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import PageMeta from "@/components/common/PageMeta";
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<>
|
||||
<PageMeta title="页面未找到" description="" />
|
||||
<div className="relative flex flex-col items-center justify-center min-h-screen p-6 overflow-hidden z-1">
|
||||
<div className="mx-auto w-full max-w-[242px] text-center sm:max-w-[472px]">
|
||||
<h1 className="mb-8 font-bold text-gray-800 text-title-md dark:text-white/90 xl:text-title-2xl">
|
||||
错误
|
||||
</h1>
|
||||
|
||||
<img src="/images/error/404.svg" alt="404" className="dark:hidden" />
|
||||
<img
|
||||
src="/images/error/404-dark.svg"
|
||||
alt="404"
|
||||
className="hidden dark:block"
|
||||
/>
|
||||
|
||||
<p className="mt-10 mb-6 text-base text-gray-700 dark:text-gray-400 sm:text-lg">
|
||||
页面可能已被删除或不存在,请检查网址是否正确。
|
||||
</p>
|
||||
|
||||
<Link
|
||||
to="/"
|
||||
className="inline-flex items-center justify-center rounded-lg border border-gray-300 bg-white px-5 py-3.5 text-sm font-medium text-gray-700 shadow-theme-xs hover:bg-gray-50 hover:text-gray-800 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-white/[0.03] dark:hover:text-gray-200"
|
||||
>
|
||||
返回首页
|
||||
</Link>
|
||||
</div>
|
||||
{/* <!-- Footer --> */}
|
||||
<p className="absolute text-sm text-center text-gray-500 -translate-x-1/2 bottom-6 left-1/2 dark:text-gray-400">
|
||||
© {new Date().getFullYear()}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Sample Page
|
||||
*/
|
||||
|
||||
import PageMeta from "../components/common/PageMeta";
|
||||
|
||||
export default function SamplePage() {
|
||||
return (
|
||||
<>
|
||||
<PageMeta title="Home" description="Home Page Introduction" />
|
||||
<div>
|
||||
<h3>This is a sample page</h3>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface UserProfile {
|
||||
name: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
avatar?: string;
|
||||
}
|
||||
|
||||
const UserSet: React.FC = () => {
|
||||
const [profile, setProfile] = useState<UserProfile>({
|
||||
name: '张三',
|
||||
email: 'zhangsan@example.com',
|
||||
phone: '13800138000',
|
||||
avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=zhangsan'
|
||||
});
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleAvatarChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
toast.error('头像文件不能超过 5MB');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!file.type.startsWith('image/')) {
|
||||
toast.error('请选择有效的图片文件');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
// 创建临时 URL 用于预览
|
||||
const tempUrl = URL.createObjectURL(file);
|
||||
|
||||
// 模拟上传过程
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
// 实际项目中这里应该上传到服务器
|
||||
// 这里我们创建一个更可靠的头像 URL
|
||||
const newAvatarUrl = `https://api.dicebear.com/7.x/avataaars/svg?seed=${Date.now()}`;
|
||||
|
||||
setProfile(prev => ({
|
||||
...prev,
|
||||
avatar: newAvatarUrl
|
||||
}));
|
||||
|
||||
// 清理临时 URL
|
||||
URL.revokeObjectURL(tempUrl);
|
||||
|
||||
toast.success('头像更新成功');
|
||||
} catch (error) {
|
||||
console.error('头像上传失败:', error);
|
||||
toast.error('头像上传失败,请重试');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleProfileUpdate = async () => {
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
// 模拟保存过程
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
toast.success('个人信息保存成功');
|
||||
} catch (error) {
|
||||
console.error('保存失败:', error);
|
||||
toast.error('保存失败,请重试');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputChange = (field: keyof UserProfile) => (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setProfile(prev => ({
|
||||
...prev,
|
||||
[field]: event.target.value
|
||||
}));
|
||||
};
|
||||
|
||||
const getAvatarSrc = (avatarUrl?: string) => {
|
||||
// 如果没有头像URL,返回空
|
||||
if (!avatarUrl) return '';
|
||||
|
||||
// 如果是相对路径,返回绝对路径
|
||||
if (avatarUrl.startsWith('/')) {
|
||||
return avatarUrl;
|
||||
}
|
||||
|
||||
// 如果已经是完整的URL,直接返回
|
||||
if (avatarUrl.startsWith('http://') || avatarUrl.startsWith('https://')) {
|
||||
return avatarUrl;
|
||||
}
|
||||
|
||||
// 其他情况,添加默认URL前缀
|
||||
return `https://api.dicebear.com/7.x/avataaars/svg?seed=${encodeURIComponent(avatarUrl)}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8 max-w-2xl">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl font-bold">个人设置</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* 头像设置 */}
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<div className="relative">
|
||||
<Avatar className="w-24 h-24 border-4 border-border shadow-lg">
|
||||
<AvatarImage
|
||||
src={getAvatarSrc(profile.avatar)}
|
||||
alt="用户头像"
|
||||
className="object-cover"
|
||||
onError={(e) => {
|
||||
// 图片加载失败时使用备用头像
|
||||
e.currentTarget.style.display = 'none';
|
||||
const fallback = e.currentTarget.parentElement?.querySelector('.avatar-fallback');
|
||||
if (fallback) {
|
||||
fallback.style.display = 'flex';
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<AvatarFallback className="avatar-fallback text-lg font-semibold bg-primary text-primary-foreground">
|
||||
{profile.name.charAt(0)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
className="absolute bottom-0 right-0 rounded-full w-8 h-8 p-0"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 13a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
</svg>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleAvatarChange}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
<p className="text-sm text-muted-foreground">
|
||||
支持 JPG、PNG 格式,文件大小不超过 5MB
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 个人信息 */}
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">姓名</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={profile.name}
|
||||
onChange={handleInputChange('name')}
|
||||
placeholder="请输入姓名"
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">邮箱</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
value={profile.email}
|
||||
onChange={handleInputChange('email')}
|
||||
placeholder="请输入邮箱"
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="phone">手机号</Label>
|
||||
<Input
|
||||
id="phone"
|
||||
value={profile.phone}
|
||||
onChange={handleInputChange('phone')}
|
||||
placeholder="请输入手机号"
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 保存按钮 */}
|
||||
<div className="flex justify-end pt-4">
|
||||
<Button
|
||||
onClick={handleProfileUpdate}
|
||||
disabled={isLoading}
|
||||
className="min-w-[100px]"
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-4 h-4 border-2 border-current border-t-transparent rounded-full animate-spin" />
|
||||
<span>保存中...</span>
|
||||
</div>
|
||||
) : (
|
||||
'保存修改'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserSet;
|
||||
@@ -0,0 +1,243 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { ArrowLeft, Sparkles, Copy, Check, Loader2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import RichTextEditor from '@/components/editor/RichTextEditor';
|
||||
import { sendChatStream } from '@/services/chatStream';
|
||||
import PageMeta from '@/components/common/PageMeta';
|
||||
|
||||
const APP_ID = import.meta.env.VITE_APP_ID;
|
||||
|
||||
const markdownToHtml = (markdown: string): string => {
|
||||
let html = markdown;
|
||||
|
||||
html = html.replace(/^### (.*$)/gim, '<h3>$1</h3>');
|
||||
html = html.replace(/^## (.*$)/gim, '<h2>$1</h2>');
|
||||
html = html.replace(/^# (.*$)/gim, '<h1>$1</h1>');
|
||||
|
||||
html = html.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>');
|
||||
html = html.replace(/\*(.*?)\*/g, '<em>$1</em>');
|
||||
|
||||
html = html.replace(/^\* (.*$)/gim, '<li>$1</li>');
|
||||
html = html.replace(/(<li>.*<\/li>)/s, '<ul>$1</ul>');
|
||||
|
||||
html = html.replace(/\n\n/g, '</p><p>');
|
||||
html = '<p>' + html + '</p>';
|
||||
|
||||
return html;
|
||||
};
|
||||
|
||||
export default function VideoScript() {
|
||||
const navigate = useNavigate();
|
||||
const [topic, setTopic] = useState('');
|
||||
const [content, setContent] = useState('');
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
const [abortController, setAbortController] = useState<AbortController | null>(null);
|
||||
|
||||
const handleGenerate = async () => {
|
||||
if (!topic.trim()) {
|
||||
toast.error('请输入视频主题');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsGenerating(true);
|
||||
setContent('');
|
||||
const controller = new AbortController();
|
||||
setAbortController(controller);
|
||||
|
||||
let markdownContent = '';
|
||||
|
||||
try {
|
||||
await sendChatStream({
|
||||
endpoint: 'https://api-integrations.appmiaoda.com/app-7a7nlc9zki69/api-2bk93oeO9NlE/v2/chat/completions',
|
||||
apiId: APP_ID,
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: '你是一个专业的视频脚本创作者,擅长为公众号视频内容创作详细的拍摄脚本。请根据用户提供的主题,生成一个完整的视频脚本,包括:标题、简介、分镜脚本(场景描述、台词、时长、拍摄要点)。脚本要详细、实用,便于执行。使用 Markdown 格式输出,用表格展示分镜脚本。'
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: `请为我创作一个关于"${topic}"的视频拍摄脚本`
|
||||
}
|
||||
],
|
||||
onUpdate: (newContent: string) => {
|
||||
markdownContent = newContent;
|
||||
const htmlContent = markdownToHtml(markdownContent);
|
||||
setContent(htmlContent);
|
||||
},
|
||||
onComplete: () => {
|
||||
setIsGenerating(false);
|
||||
setAbortController(null);
|
||||
toast.success('脚本生成完成');
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
setIsGenerating(false);
|
||||
setAbortController(null);
|
||||
toast.error(`生成失败: ${error.message}`);
|
||||
},
|
||||
signal: controller.signal
|
||||
});
|
||||
} catch (error) {
|
||||
if (!controller.signal.aborted) {
|
||||
setIsGenerating(false);
|
||||
setAbortController(null);
|
||||
toast.error('生成失败,请重试');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleStop = () => {
|
||||
if (abortController) {
|
||||
abortController.abort();
|
||||
setAbortController(null);
|
||||
setIsGenerating(false);
|
||||
toast.info('已停止生成');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
const tempDiv = document.createElement('div');
|
||||
tempDiv.innerHTML = content;
|
||||
|
||||
const blob = new Blob([tempDiv.innerHTML], { type: 'text/html' });
|
||||
const clipboardItem = new ClipboardItem({
|
||||
'text/html': blob,
|
||||
'text/plain': new Blob([tempDiv.innerText], { type: 'text/plain' })
|
||||
});
|
||||
|
||||
await navigator.clipboard.write([clipboardItem]);
|
||||
setIsCopied(true);
|
||||
toast.success('脚本已复制');
|
||||
|
||||
setTimeout(() => setIsCopied(false), 2000);
|
||||
} catch (error) {
|
||||
toast.error('复制失败,请手动选择复制');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageMeta title="视频脚本 - 公众号推文助手" description="输入视频主题,AI 将为您生成详细的拍摄脚本" />
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="mb-6"
|
||||
onClick={() => navigate('/')}
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
返回首页
|
||||
</Button>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Sparkles className="h-5 w-5 text-primary" />
|
||||
视频脚本生成
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
输入视频主题,AI 将为您生成详细的拍摄脚本
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="topic">视频主题</Label>
|
||||
<Textarea
|
||||
id="topic"
|
||||
placeholder="例如:产品使用教程、品牌故事讲述、活动现场记录..."
|
||||
value={topic}
|
||||
onChange={(e) => setTopic(e.target.value)}
|
||||
rows={4}
|
||||
disabled={isGenerating}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isGenerating ? (
|
||||
<Button
|
||||
className="w-full"
|
||||
variant="destructive"
|
||||
onClick={handleStop}
|
||||
>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
停止生成
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={handleGenerate}
|
||||
disabled={!topic.trim()}
|
||||
>
|
||||
<Sparkles className="mr-2 h-4 w-4" />
|
||||
生成脚本
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{content && !isGenerating && (
|
||||
<Button
|
||||
className="w-full"
|
||||
variant="outline"
|
||||
onClick={handleCopy}
|
||||
>
|
||||
{isCopied ? (
|
||||
<>
|
||||
<Check className="mr-2 h-4 w-4" />
|
||||
已复制
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
复制脚本
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<div className="text-sm text-muted-foreground space-y-2 pt-4 border-t">
|
||||
<p className="font-semibold">使用提示:</p>
|
||||
<ul className="list-disc list-inside space-y-1">
|
||||
<li>详细描述视频主题和目标受众</li>
|
||||
<li>脚本包含分镜、台词、时长等信息</li>
|
||||
<li>可以在编辑器中修改和完善脚本</li>
|
||||
<li>适合短视频和中长视频制作</li>
|
||||
</ul>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>脚本预览与编辑</CardTitle>
|
||||
<CardDescription>
|
||||
在这里查看和编辑生成的视频脚本
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{content ? (
|
||||
<RichTextEditor
|
||||
content={content}
|
||||
onChange={setContent}
|
||||
/>
|
||||
) : (
|
||||
<div className="border rounded-lg p-8 text-center text-muted-foreground min-h-[400px] flex items-center justify-center">
|
||||
<div>
|
||||
<Sparkles className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
||||
<p>输入主题并点击生成,脚本将在这里显示</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user