"添加前端模板和运行代码模块"

This commit is contained in:
Codex
2026-06-01 13:43:09 +08:00
parent 24045274ad
commit 8092c4b1f8
587 changed files with 99761 additions and 0 deletions

View File

@@ -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>
</>
);
}

View File

@@ -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>
</>
);
}

View File

@@ -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>
</>
);
}

View File

@@ -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">
JPGPNGBMP 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> JPGPNGBMP </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>
</>
);
}

View File

@@ -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">
&copy; {new Date().getFullYear()}
</p>
</div>
</>
);
}

View File

@@ -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>
</>
);
}

View File

@@ -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">
JPGPNG 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;

View File

@@ -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>
</>
);
}