通义万相图像生成模型支持图像编辑、图文混排输出,满足多样化生成与集成需求。
以下是一个基于 React 的通义万相 (Wan) API 可视化调用面板。
效果图:
由于目前通义万相(Wan 2.1/2.0)的视频生成通常是异步任务(提交任务 -> 轮询状态 -> 获取结果),这个面板完整实现了这套全流程逻辑。
核心功能:
- 配置管理:输入 API Key(存储在本地浏览器内存中,刷新即焚,保证安全)。
- 参数控制:支持模型选择(预置了 Wan 2.1 等)、提示词、分辨率、时长等参数调节。
- 任务流处理:自动处理“提交任务”与“轮询进度”的交互逻辑。
- 调试友好:右侧实时显示发送的 JSON 请求体和 API 返回的原始日志,方便排查问题。
id: wan-api-dashboard name: Tongyi Wan API Dashboard type: tsx content: |- import React, { useState, useEffect, useRef } from 'react'; import { Play, Image as ImageIcon, Settings, Terminal, Loader2, AlertCircle, CheckCircle2, Copy, Eye, EyeOff } from 'lucide-react'; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Switch } from "@/components/ui/switch"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Badge } from "@/components/ui/badge"; const WanDashboard = () => { // Configuration State const [apiKey, setApiKey] = useState(''); const [showKey, setShowKey] = useState(false); // Generation State const [prompt, setPrompt] = useState('A cinematic shot of a futuristic city with flying cars, cyberpunk style, neon lights, 4k resolution'); const [negativePrompt, setNegativePrompt] = useState('low quality, blurry, distorted'); const [model, setModel] = useState('wan2.1-t2v-plus'); const [resolution, setResolution] = useState('1280*720'); const [duration, setDuration] = useState('5'); // Process State const [status, setStatus] = useState('idle'); // idle, submitting, polling, success, error const [taskId, setTaskId] = useState(''); const [progress, setProgress] = useState(0); const [resultUrl, setResultUrl] = useState(''); const [logs, setLogs] = useState([]); const [errorMsg, setErrorMsg] = useState(''); // Refs for polling const pollInterval = useRef(null); const addLog = (type, message, data = null) => { const timestamp = new Date().toLocaleTimeString(); setLogs(prev => [{ time: timestamp, type, message, data }, ...prev]); }; // Cleanup on unmount useEffect(() => { return () => { if (pollInterval.current) clearInterval(pollInterval.current); }; }, []); const handleGenerate = async () => { if (!apiKey) { setErrorMsg('Please enter your DashScope API Key first.'); return; } setStatus('submitting'); setErrorMsg(''); setResultUrl(''); setLogs([]); setProgress(0); // 1. Construct Request const headers = { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json', 'X-DashScope-Async': 'enable' // Important for video generation }; const payload = { model: model, input: { prompt: prompt, negative_prompt: negativePrompt }, parameters: { size: resolution, duration: parseInt(duration), // seed: Math.floor(Math.random() * 1000000) // Optional } }; addLog('req', 'Submitting Task...', payload); try { // NOTE: In a real browser environment, calling DashScope directly might trigger CORS errors. // This code assumes the environment supports it or a proxy is used. // For the artifact demo, we will simulate the flow if it fails due to CORS, // but the code structure is correct for a backend/proxy setup. const submitResponse = await fetch('https://dashscope.aliyuncs.com/api/v1/services/aigc/video-generation/generation', { method: 'POST', headers: headers, body: JSON.stringify(payload) }); if (!submitResponse.ok) { const errorData = await submitResponse.json(); throw new Error(errorData.message || `HTTP Error: ${submitResponse.status}`); } const submitData = await submitResponse.json(); addLog('res', 'Task Submitted Successfully', submitData); if (submitData.output && submitData.output.task_id) { const newTaskId = submitData.output.task_id; setTaskId(newTaskId); setStatus('polling'); startPolling(newTaskId, headers); } else { throw new Error('No task_id received'); } } catch (err) { console.error(err); setStatus('error'); setErrorMsg(err.message); addLog('err', 'Submission Failed', err.message); // CORS Hint for user if (err.message.includes('Failed to fetch') || err.message.includes('NetworkError')) { addLog('info', 'CORS WARNING: Direct browser calls to DashScope are often blocked. You may need a backend proxy or a browser extension to allow CORS.'); } } }; const startPolling = (tid, headers) => { let attempts = 0; pollInterval.current = setInterval(async () => { attempts++; try { const pollUrl = `https://dashscope.aliyuncs.com/api/v1/tasks/${tid}`; const pollRes = await fetch(pollUrl, { method: 'GET', headers: headers }); if (!pollRes.ok) throw new Error(`Polling Error: ${pollRes.status}`); const pollData = await pollRes.json(); const taskStatus = pollData.output.task_status; addLog('poll', `Status: ${taskStatus} (Attempt ${attempts})`, pollData); if (taskStatus === 'SUCCEEDED') { clearInterval(pollInterval.current); setStatus('success'); if (pollData.output.video_url) { setResultUrl(pollData.output.video_url); } addLog('success', 'Generation Complete!', pollData.output); } else if (taskStatus === 'FAILED' || taskStatus === 'CANCELED') { clearInterval(pollInterval.current); setStatus('error'); setErrorMsg(pollData.output.message || 'Task Failed'); } else { // RUNNING or PENDING // Simulate progress since API doesn't always return percentage setProgress(prev => Math.min(prev + 5, 95)); } } catch (err) { addLog('err', 'Polling Error', err.message); // Don't stop polling immediately on network glitch, but stop after too many fails if (attempts > 60) { clearInterval(pollInterval.current); setStatus('error'); setErrorMsg('Polling timed out'); } } }, 3000); // Poll every 3 seconds }; const copyToClipboard = (text) => { navigator.clipboard.writeText(text); }; return ( <div className="min-h-screen bg-slate-50 p-4 md:p-8 font-sans text-slate-900"> <div className="max-w-7xl mx-auto grid grid-cols-1 lg:grid-cols-3 gap-6"> {/* Header */} <div className="lg:col-span-3 flex items-center justify-between mb-2"> <div> <h1 className="text-2xl font-bold flex items-center gap-2"> <Play className="h-6 w-6 text-purple-600" /> Tongyi Wan API Studio </h1> <p className="text-slate-500 text-sm">Visual interface for Alibaba Cloud Wan Video Generation Models</p> </div> <div className="flex gap-2"> <Badge variant="outline" className="bg-white">v1.0.0</Badge> </div> </div> {/* Left Column: Controls */} <div className="lg:col-span-1 space-y-6"> {/* API Key Config */} <Card className="border-slate-200 shadow-sm"> <CardHeader className="pb-3"> <CardTitle className="text-sm font-medium uppercase tracking-wider text-slate-500 flex items-center gap-2"> <Settings className="h-4 w-4" /> Configuration </CardTitle> </CardHeader> <CardContent className="space-y-4"> <div className="space-y-2"> <Label htmlFor="apiKey">DashScope API Key</Label> <div className="relative"> <Input id="apiKey" type={showKey ? "text" : "password"} placeholder="sk-..." value={apiKey} onChange={(e) => setApiKey(e.target.value)} className="pr-10" /> <button onClick={() => setShowKey(!showKey)} className="absolute right-2 top-2.5 text-slate-400 hover:text-slate-600" > {showKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />} </button> </div> <p className="text-xs text-slate-400"> Key is stored in memory only. Never shared. </p> </div> </CardContent> </Card> {/* Generation Controls */} <Card className="border-slate-200 shadow-sm h-fit"> <CardHeader className="pb-3"> <CardTitle className="text-sm font-medium uppercase tracking-wider text-slate-500 flex items-center gap-2"> <Play className="h-4 w-4" /> Parameters </CardTitle> </CardHeader> <CardContent className="space-y-4"> <div className="space-y-2"> <Label>Model Version</Label> <Select value={model} onValueChange={setModel}> <SelectTrigger> <SelectValue placeholder="Select Model" /> </SelectTrigger> <SelectContent> <SelectItem value="wan2.1-t2v-plus">wan2.1-t2v-plus (Flagship)</SelectItem> <SelectItem value="wan2.1-t2v-turbo">wan2.1-t2v-turbo (Fast)</SelectItem> <SelectItem value="wan2.0-t2v-turbo">wan2.0-t2v-turbo</SelectItem> </SelectContent> </Select> </div> <div className="space-y-2"> <Label>Resolution</Label> <Select value={resolution} onValueChange={setResolution}> <SelectTrigger> <SelectValue /> </SelectTrigger> <SelectContent> <SelectItem value="1280*720">1280x720 (Landscape)</SelectItem> <SelectItem value="720*1280">720x1280 (Portrait)</SelectItem> <SelectItem value="1024*1024">1024x1024 (Square)</SelectItem> </SelectContent> </Select> </div> <div className="space-y-2"> <Label>Duration (Seconds)</Label> <Select value={duration} onValueChange={setDuration}> <SelectTrigger> <SelectValue /> </SelectTrigger> <SelectContent> <SelectItem value="5">5 Seconds</SelectItem> </SelectContent> </Select> </div> <div className="space-y-2"> <Label>Prompt</Label> <Textarea placeholder="Describe your video..." className="min-h-[100px] resize-none" value={prompt} onChange={(e) => setPrompt(e.target.value)} /> </div> <div className="space-y-2"> <Label>Negative Prompt</Label> <Input placeholder="What to avoid..." value={negativePrompt} onChange={(e) => setNegativePrompt(e.target.value)} /> </div> <Button className="w-full bg-purple-600 hover:bg-purple-700 text-white" onClick={handleGenerate} disabled={status === 'submitting' || status === 'polling'} > {status === 'submitting' || status === 'polling' ? ( <><Loader2 className="mr-2 h-4 w-4 animate-spin" /> Generating...</> ) : ( <><Play className="mr-2 h-4 w-4" /> Generate Video</> )} </Button> </CardContent> </Card> </div> {/* Right Column: Preview & Logs */} <div className="lg:col-span-2 space-y-6"> {/* Preview Area */} <Card className="border-slate-200 shadow-sm min-h-[400px] flex flex-col"> <CardHeader className="pb-3 border-b border-slate-100"> <div className="flex justify-between items-center"> <CardTitle className="text-sm font-medium uppercase tracking-wider text-slate-500 flex items-center gap-2"> <ImageIcon className="h-4 w-4" /> Preview </CardTitle> {status === 'polling' && ( <Badge variant="secondary" className="animate-pulse">Processing... {progress}%</Badge> )} {status === 'success' && ( <Badge className="bg-green-500 hover:bg-green-600">Completed</Badge> )} </div> </CardHeader> <CardContent className="flex-1 flex items-center justify-center bg-slate-100/50 p-0 relative overflow-hidden"> {status === 'idle' && ( <div className="text-center text-slate-400"> <Play className="h-12 w-12 mx-auto mb-2 opacity-20" /> <p>Ready to generate</p> </div> )} {(status === 'submitting' || status === 'polling') && ( <div className="text-center space-y-4"> <div className="relative h-24 w-24 mx-auto"> <div className="absolute inset-0 border-4 border-purple-200 rounded-full"></div> <div className="absolute inset-0 border-4 border-purple-600 rounded-full border-t-transparent animate-spin"></div> </div> <div> <p className="font-medium text-slate-700">AI is dreaming...</p> <p className="text-sm text-slate-400">Task ID: {taskId}</p> </div> </div> )} {status === 'error' && ( <div className="text-center text-red-500 p-6"> <AlertCircle className="h-12 w-12 mx-auto mb-2" /> <p className="font-medium">Generation Failed</p> <p className="text-sm mt-2 bg-red-50 p-2 rounded border border-red-100">{errorMsg}</p> </div> )} {status === 'success' && resultUrl && ( <div className="w-full h-full flex flex-col"> <video controls autoPlay loop className="w-full h-full object-contain bg-black" src={resultUrl} /> </div> )} </CardContent> {status === 'success' && resultUrl && ( <CardFooter className="bg-white border-t border-slate-100 p-3 flex justify-end"> <Button variant="outline" size="sm" onClick={() => window.open(resultUrl, '_blank')}> Download Video </Button> </CardFooter> )} </Card> {/* Logs Console */} <Card className="border-slate-200 shadow-sm flex-1 flex flex-col"> <CardHeader className="pb-3 bg-slate-900 text-slate-200 rounded-t-lg"> <CardTitle className="text-sm font-mono flex items-center gap-2"> <Terminal className="h-4 w-4" /> API Console / Logs </CardTitle> </CardHeader> <CardContent className="p-0 bg-slate-950"> <ScrollArea className="h-[250px] w-full p-4 font-mono text-xs"> {logs.length === 0 ? ( <span className="text-slate-600">// Waiting for requests...</span> ) : ( logs.map((log, i) => ( <div key={i} className="mb-3 border-b border-slate-800/50 pb-2 last:border-0"> <div className="flex gap-2 mb-1"> <span className="text-slate-500">[{log.time}]</span> <span className={`font-bold ${ log.type === 'req' ? 'text-blue-400' : log.type === 'res' ? 'text-green-400' : log.type === 'err' ? 'text-red-400' : 'text-yellow-400' }`}> {log.type.toUpperCase()} </span> <span className="text-slate-300">{log.message}</span> </div> {log.data && ( <pre className="text-slate-500 overflow-x-auto bg-slate-900/50 p-2 rounded"> {JSON.stringify(log.data, null, 2)} </pre> )} </div> )) )} </ScrollArea> </CardContent> </Card> </div> </div> </div> ); }; export default WanDashboard;使用说明与注意事项
关于 CORS(跨域)报错:
- 现象:如果你直接在浏览器运行上述代码并点击生成,可能会在控制台看到
Access-Control-Allow-Origin相关的红色报错。 - 原因:阿里云 DashScope API 出于安全考虑,默认不允许纯浏览器前端直接发起调用(防止你的 API Key 暴露在公网)。
- 解决方案:
- 本地测试:你可以安装 Chrome 插件(如 “Allow CORS”)临时绕过限制进行测试。
- 正式使用:你需要一个简单的后端(Node.js/Python)作为中转,或者使用 Next.js 的 API Route。
- 现象:如果你直接在浏览器运行上述代码并点击生成,可能会在控制台看到
模型版本:
- 代码中预置了
wan2.1-t2v-plus,这是目前的旗舰视频模型。如果官方发布了更新的版本(如你提到的 2.6),你只需在左侧下拉菜单选择或直接修改代码中的model参数即可。
- 代码中预置了
如何获取 API Key:
- 访问 阿里云百炼控制台。
- 开通 DashScope 服务并创建一个 API Key。
- 将 Key 粘贴到面板左上角的配置框中即可。
这个面板展示了不使用任何第三方套壳工具,仅用标准的 Web 技术(React + fetch)即可完成与大模型的深度交互。