Better UX

This commit is contained in:
Xin Wang
2026-02-04 18:36:40 +08:00
parent 47207dab19
commit b608c395c7
14 changed files with 877 additions and 403 deletions

View File

@@ -1,6 +1,6 @@
import React, { useState } from 'react';
import { Plus, Search, Play, Copy, Trash2, Zap, MessageSquare, Mic, AlertTriangle, ListFilter, Braces, Rocket } from 'lucide-react';
import { Plus, Search, Play, Copy, Trash2, Zap, MessageSquare, Mic, AlertTriangle, ClipboardCheck, X } from 'lucide-react';
import { Button, Input, Card, Badge, Dialog } from '../components/UI';
import { mockAutoTestAssistants, mockAssistants } from '../services/mockData';
import { AutoTestAssistant, TestType, TestMethod } from '../types';
@@ -9,93 +9,87 @@ export const AutoTestPage: React.FC = () => {
const [testAssistants, setTestAssistants] = useState<AutoTestAssistant[]>(mockAutoTestAssistants);
const [searchTerm, setSearchTerm] = useState('');
const [selectedId, setSelectedId] = useState<string | null>(null);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [deleteId, setDeleteId] = useState<string | null>(null);
const [copySuccess, setCopySuccess] = useState(false);
const selectedTestAssistant = testAssistants.find(a => a.id === selectedId) || null;
const filteredTests = testAssistants.filter(a =>
a.name.toLowerCase().includes(searchTerm.toLowerCase())
const filteredTests = testAssistants.filter(t =>
t.name.toLowerCase().includes(searchTerm.toLowerCase())
);
const selectedTest = testAssistants.find(t => t.id === selectedId) || null;
const handleCreate = () => {
const newId = `at_${Date.now()}`;
const newAssistant: AutoTestAssistant = {
const newId = crypto.randomUUID();
const newTest: AutoTestAssistant = {
id: newId,
name: '新测试助手',
type: TestType.INTELLIGENT,
name: '新测试任务',
type: TestType.FIXED,
method: TestMethod.TEXT,
targetAssistantId: mockAssistants[0]?.id || '',
fixedWorkflowSteps: [],
intelligentPrompt: '你是一个普通的测试用户,试图了解产品信息。',
createdAt: new Date().toISOString().replace('T', ' ').substring(0, 16)
intelligentPrompt: '',
createdAt: new Date().toISOString().split('T')[0],
};
setTestAssistants([...testAssistants, newAssistant]);
setTestAssistants([newTest, ...testAssistants]);
setSelectedId(newId);
};
const handleCopy = (e: React.MouseEvent, assistant: AutoTestAssistant) => {
const handleCopy = (e: React.MouseEvent, test: AutoTestAssistant) => {
e.stopPropagation();
const newAssistant = { ...assistant, id: `at_${Date.now()}`, name: `${assistant.name} (Copy)` };
setTestAssistants([...testAssistants, newAssistant]);
const newId = crypto.randomUUID();
const newTest: AutoTestAssistant = {
...test,
id: newId,
name: `${test.name} (复制)`,
createdAt: new Date().toISOString().split('T')[0],
};
setTestAssistants([newTest, ...testAssistants]);
setSelectedId(newId);
};
const updateTest = (field: keyof AutoTestAssistant, value: any) => {
if (!selectedId) return;
setTestAssistants(prev => prev.map(t => t.id === selectedId ? { ...t, [field]: value } : t));
};
const handleDeleteClick = (e: React.MouseEvent, id: string) => {
e.stopPropagation();
setDeleteId(id);
setIsDeleteModalOpen(true);
};
const confirmDelete = () => {
if (deleteId) {
setTestAssistants(prev => prev.filter(a => a.id !== deleteId));
setTestAssistants(prev => prev.filter(t => t.id !== deleteId));
if (selectedId === deleteId) setSelectedId(null);
setIsDeleteModalOpen(false);
setDeleteId(null);
}
};
const updateAssistant = (field: keyof AutoTestAssistant, value: any) => {
if (!selectedId) return;
setTestAssistants(prev => prev.map(a => a.id === selectedId ? { ...a, [field]: value } : a));
};
const handleAddStep = () => {
if (selectedTestAssistant) {
updateAssistant('fixedWorkflowSteps', [...selectedTestAssistant.fixedWorkflowSteps, '']);
}
};
const updateStep = (idx: number, val: string) => {
if (selectedTestAssistant) {
const newSteps = [...selectedTestAssistant.fixedWorkflowSteps];
newSteps[idx] = val;
updateAssistant('fixedWorkflowSteps', newSteps);
}
};
const removeStep = (idx: number) => {
if (selectedTestAssistant) {
updateAssistant('fixedWorkflowSteps', selectedTestAssistant.fixedWorkflowSteps.filter((_, i) => i !== idx));
}
const handleCopyId = (id: string) => {
navigator.clipboard.writeText(id);
setCopySuccess(true);
setTimeout(() => setCopySuccess(false), 2000);
};
return (
<div className="flex h-[calc(100vh-6rem)] gap-6 animate-in fade-in">
{/* LEFT COLUMN: Test Assistants List */}
<div className="flex h-[calc(100vh-8rem)] gap-6 animate-in fade-in py-4">
{/* Left List */}
<div className="w-80 flex flex-col gap-4 shrink-0">
<div className="flex items-center justify-between px-1">
<h2 className="text-xl font-bold tracking-tight"></h2>
</div>
<h2 className="text-xl font-bold tracking-tight text-white"></h2>
<div className="flex gap-2">
<div className="relative flex-1">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="搜索测试助手..."
placeholder="搜索测试..."
className="pl-9 bg-card/50 border-white/5"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<Button size="icon" onClick={handleCreate} title="新建测试助手">
<Button size="icon" onClick={handleCreate} title="新建测试">
<Plus className="h-5 w-5" />
</Button>
</div>
@@ -111,214 +105,208 @@ export const AutoTestPage: React.FC = () => {
: 'bg-card/30 border-white/5 hover:bg-white/5 hover:border-white/10'
}`}
>
<div className="flex justify-between items-start mb-2">
<span className={`font-semibold truncate pr-6 ${selectedId === test.id ? 'text-primary' : 'text-foreground'}`}>
<div className="flex flex-col gap-1.5 mb-2 pr-16 overflow-hidden">
<span className={`font-semibold truncate ${selectedId === test.id ? 'text-primary' : 'text-foreground'}`}>
{test.name}
</span>
</div>
<div className="flex items-center space-x-3 text-[10px] text-muted-foreground font-mono uppercase">
<Badge variant="outline" className="text-[9px] py-0 px-1.5 opacity-70">
{test.type === TestType.FIXED ? '固定流程' : '智能测试'}
</Badge>
<div className="flex items-center">
{test.method === TestMethod.TEXT ? <MessageSquare className="h-2.5 w-2.5 mr-1" /> : <Mic className="h-2.5 w-2.5 mr-1" />}
{test.method === TestMethod.TEXT ? '文本' : '音频'}
<div className="flex">
<Badge
variant="outline"
className={`text-[9px] uppercase tracking-tighter shrink-0 opacity-70 border-white/10 ${
test.type === TestType.FIXED ? 'text-blue-400 bg-blue-400/5' : 'text-purple-400 bg-purple-400/5'
}`}
>
{test.type === TestType.FIXED ? '固定' : '智能'}
</Badge>
</div>
</div>
{/* Hover Actions */}
<div className="absolute right-2 top-2 flex space-x-1 opacity-0 group-hover:opacity-100 transition-opacity bg-background/50 backdrop-blur-sm rounded-lg p-0.5">
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={(e) => handleCopy(e, test)} title="复制">
<div className="text-[10px] text-muted-foreground font-mono opacity-60">
: {test.createdAt}
</div>
{/* Hover Actions Toolbar */}
<div className="absolute right-2 top-2 flex space-x-1 opacity-0 group-hover:opacity-100 transition-opacity bg-background/50 backdrop-blur-sm rounded-lg p-0.5 shadow-lg border border-white/5">
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={(e) => handleCopy(e, test)} title="复制测试任务">
<Copy className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive hover:text-destructive" onClick={(e) => handleDeleteClick(e, test.id)} title="删除">
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive hover:text-destructive" onClick={(e) => handleDeleteClick(e, test.id)} title="删除测试任务">
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
))}
{filteredTests.length === 0 && (
<div className="text-center py-10 text-muted-foreground text-sm font-mono opacity-50">
[ NO TESTERS FOUND ]
<div className="text-center py-10 text-muted-foreground text-sm">
</div>
)}
</div>
</div>
{/* RIGHT COLUMN: Config Panel */}
{/* Right Config Panel */}
<div className="flex-1 bg-card/20 backdrop-blur-sm border border-white/5 rounded-2xl overflow-hidden flex flex-col relative shadow-xl">
{selectedTestAssistant ? (
<>
{/* Header Area */}
<div className="p-6 border-b border-white/5 bg-white/[0.02] space-y-4">
{selectedTest ? (
<div className="flex flex-col h-full overflow-hidden">
<div className="p-6 border-b border-white/5 bg-white/[0.02]">
<div className="flex items-end justify-between gap-4">
<div className="flex-1">
<label className="text-[10px] text-muted-foreground font-black uppercase tracking-widest mb-2 block ml-1">TESTER NAME</label>
<div className="flex items-center justify-between mb-2">
<label className="text-[10px] text-muted-foreground font-black tracking-widest uppercase ml-1"></label>
</div>
<Input
value={selectedTestAssistant.name}
onChange={(e) => updateAssistant('name', e.target.value)}
value={selectedTest.name}
onChange={(e) => updateTest('name', e.target.value)}
className="font-bold bg-white/5 border-white/10 focus:border-primary/50 text-base"
/>
</div>
<Button
onClick={() => alert("开始自动化测试...")}
className="shadow-[0_0_20px_rgba(34,197,94,0.3)] bg-green-500 hover:bg-green-600 text-white font-bold"
>
<Rocket className="mr-2 h-4 w-4" />
<Button onClick={() => alert("开始自动化测试脚本生成...")}>
<Zap className="mr-2 h-4 w-4" />
</Button>
</div>
</div>
{/* Scroll Area */}
<div className="flex-1 overflow-y-auto p-8 custom-scrollbar">
<div className="max-w-4xl mx-auto space-y-8 animate-in slide-in-from-bottom-2 duration-300">
{/* Basic Config Grid */}
<div className="grid grid-cols-2 gap-8">
<div className="space-y-3">
<label className="text-[10px] font-black text-muted-foreground uppercase tracking-[0.2em] flex items-center">
<Zap className="h-3 w-3 mr-2 text-primary" />
</label>
<div className="flex p-1 bg-white/5 rounded-lg">
<button
onClick={() => updateAssistant('type', TestType.FIXED)}
className={`flex-1 py-1.5 text-xs font-bold rounded-md transition-all ${selectedTestAssistant.type === TestType.FIXED ? 'bg-primary text-primary-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground hover:bg-white/5'}`}
>
</button>
<button
onClick={() => updateAssistant('type', TestType.INTELLIGENT)}
className={`flex-1 py-1.5 text-xs font-bold rounded-md transition-all ${selectedTestAssistant.type === TestType.INTELLIGENT ? 'bg-primary text-primary-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground hover:bg-white/5'}`}
>
</button>
</div>
<div className="max-w-3xl mx-auto space-y-8 animate-in slide-in-from-bottom-2">
{/* Basic Config */}
<div className="grid grid-cols-2 gap-6">
<div className="space-y-2">
<label className="text-sm font-medium text-white"></label>
<select
className="flex h-10 w-full rounded-md border border-white/10 bg-white/5 px-3 py-1 text-sm text-foreground focus:ring-1 focus:ring-primary/50 [&>option]:bg-card"
value={selectedTest.targetAssistantId}
onChange={(e) => updateTest('targetAssistantId', e.target.value)}
>
{mockAssistants.map(a => (
<option key={a.id} value={a.id}>{a.name}</option>
))}
</select>
</div>
<div className="space-y-3">
<label className="text-[10px] font-black text-muted-foreground uppercase tracking-[0.2em] flex items-center">
<ListFilter className="h-3 w-3 mr-2 text-primary" />
</label>
<div className="flex p-1 bg-white/5 rounded-lg">
<button
onClick={() => updateAssistant('method', TestMethod.TEXT)}
className={`flex-1 py-1.5 text-xs font-bold rounded-md transition-all ${selectedTestAssistant.method === TestMethod.TEXT ? 'bg-primary text-primary-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground hover:bg-white/5'}`}
<div className="space-y-2">
<label className="text-sm font-medium text-white"></label>
<div className="flex bg-white/5 p-1 rounded-lg">
<button
onClick={() => updateTest('method', TestMethod.TEXT)}
className={`flex-1 flex items-center justify-center py-1.5 text-xs rounded-md transition-all ${selectedTest.method === TestMethod.TEXT ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-white/5'}`}
>
<MessageSquare className="w-3 h-3 mr-1.5" />
</button>
<button
onClick={() => updateAssistant('method', TestMethod.AUDIO)}
className={`flex-1 py-1.5 text-xs font-bold rounded-md transition-all ${selectedTestAssistant.method === TestMethod.AUDIO ? 'bg-primary text-primary-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground hover:bg-white/5'}`}
<button
onClick={() => updateTest('method', TestMethod.AUDIO)}
className={`flex-1 flex items-center justify-center py-1.5 text-xs rounded-md transition-all ${selectedTest.method === TestMethod.AUDIO ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-white/5'}`}
>
<Mic className="w-3 h-3 mr-1.5" />
</button>
</div>
</div>
</div>
<div className="space-y-3">
<label className="text-[10px] font-black text-muted-foreground uppercase tracking-[0.2em]"> (TARGET ASSISTANT)</label>
<select
className="flex h-10 w-full rounded-lg border border-white/10 bg-white/5 px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50 [&>option]:bg-card text-foreground"
value={selectedTestAssistant.targetAssistantId}
onChange={(e) => updateAssistant('targetAssistantId', e.target.value)}
>
{mockAssistants.map(a => (
<option key={a.id} value={a.id}>{a.name}</option>
))}
</select>
{/* Test Logic Type */}
<div className="space-y-4">
<label className="text-sm font-medium text-white"></label>
<div className="grid grid-cols-2 gap-4">
<Card
className={`p-4 cursor-pointer transition-all border-2 ${selectedTest.type === TestType.FIXED ? 'border-primary bg-primary/5' : 'border-white/5'}`}
onClick={() => updateTest('type', TestType.FIXED)}
>
<div className="flex items-center space-x-3 mb-2">
<Badge variant={selectedTest.type === TestType.FIXED ? 'default' : 'outline'}></Badge>
</div>
<p className="text-[10px] text-muted-foreground text-white/70"></p>
</Card>
<Card
className={`p-4 cursor-pointer transition-all border-2 ${selectedTest.type === TestType.INTELLIGENT ? 'border-primary bg-primary/5' : 'border-white/5'}`}
onClick={() => updateTest('type', TestType.INTELLIGENT)}
>
<div className="flex items-center space-x-3 mb-2">
<Badge variant={selectedTest.type === TestType.INTELLIGENT ? 'default' : 'outline'}></Badge>
</div>
<p className="text-[10px] text-muted-foreground text-white/70"> AI </p>
</Card>
</div>
</div>
{/* Conditional Settings */}
<div className="pt-6 border-t border-white/5">
{selectedTestAssistant.type === TestType.FIXED ? (
<div className="space-y-4 animate-in fade-in duration-300">
<div className="flex items-center justify-between">
<label className="text-[10px] font-black text-muted-foreground uppercase tracking-[0.2em] flex items-center">
<Braces className="h-3 w-3 mr-2 text-primary" /> (Steps)
</label>
<Button variant="outline" size="sm" onClick={handleAddStep} className="h-7 text-[10px]">
<Plus className="w-3 h-3 mr-1" />
</Button>
</div>
<div className="space-y-3">
{selectedTestAssistant.fixedWorkflowSteps.map((step, idx) => (
<div key={idx} className="flex items-center gap-3 group">
<div className="flex items-center justify-center w-6 h-6 rounded bg-primary/20 text-[10px] font-mono text-primary font-bold shrink-0">
{idx + 1}
</div>
<Input
value={step}
onChange={(e) => updateStep(idx, e.target.value)}
placeholder={`步骤 ${idx + 1} 的测试输入...`}
className="bg-white/5"
/>
<Button variant="ghost" size="icon" className="h-8 w-8 text-muted-foreground hover:text-destructive opacity-0 group-hover:opacity-100" onClick={() => removeStep(idx)}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
))}
{selectedTestAssistant.fixedWorkflowSteps.length === 0 && (
<div className="text-center py-8 border border-dashed border-white/5 rounded-xl text-xs text-muted-foreground">
</div>
)}
</div>
{/* Content Config */}
{selectedTest.type === TestType.FIXED ? (
<div className="space-y-4">
<label className="text-sm font-medium text-white"></label>
<div className="space-y-2">
{selectedTest.fixedWorkflowSteps.map((step, idx) => (
<div key={idx} className="flex gap-2 animate-in slide-in-from-left-2" style={{ animationDelay: `${idx * 50}ms` }}>
<div className="h-9 w-9 shrink-0 flex items-center justify-center bg-white/5 rounded-lg text-xs font-mono text-muted-foreground">{idx + 1}</div>
<Input
value={step}
onChange={(e) => {
const newSteps = [...selectedTest.fixedWorkflowSteps];
newSteps[idx] = e.target.value;
updateTest('fixedWorkflowSteps', newSteps);
}}
placeholder="输入用户消息..."
/>
<Button variant="ghost" size="icon" className="shrink-0" onClick={() => {
updateTest('fixedWorkflowSteps', selectedTest.fixedWorkflowSteps.filter((_, i) => i !== idx));
}}>
<X size={14} className="text-destructive" />
</Button>
</div>
))}
<Button variant="outline" className="w-full border-dashed" onClick={() => {
updateTest('fixedWorkflowSteps', [...selectedTest.fixedWorkflowSteps, '']);
}}>
<Plus size={14} className="mr-2" />
</Button>
</div>
) : (
<div className="space-y-4 animate-in fade-in duration-300">
<label className="text-[10px] font-black text-muted-foreground uppercase tracking-[0.2em] flex items-center">
<Zap className="h-3 w-3 mr-2 text-primary" /> (System Prompt)
</label>
<textarea
className="flex min-h-[250px] w-full rounded-xl border border-white/10 bg-white/5 px-4 py-3 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50 resize-y"
value={selectedTestAssistant.intelligentPrompt}
onChange={(e) => updateAssistant('intelligentPrompt', e.target.value)}
placeholder="在此设定测试助手的身份、测试目标和预期行为..."
/>
<p className="text-[10px] text-muted-foreground leading-relaxed opacity-60">
Prompt
</p>
</div>
) : (
<div className="space-y-4">
<label className="text-sm font-medium text-white"> (Intelligent Prompt)</label>
<textarea
className="w-full h-48 bg-white/5 border border-white/10 rounded-xl p-4 text-sm focus:outline-none focus:ring-1 focus:ring-primary/50 text-white placeholder:text-muted-foreground/30 leading-relaxed"
value={selectedTest.intelligentPrompt}
onChange={(e) => updateTest('intelligentPrompt', e.target.value)}
placeholder="描述扮演的角色背景、性格特点以及本次测试需要达成的目标..."
/>
<div className="p-3 bg-primary/5 border border-primary/20 rounded-lg text-[10px] text-muted-foreground flex items-start gap-2">
<Zap className="w-3.5 h-3.5 text-primary shrink-0 mt-0.5" />
<p>使 AI </p>
</div>
)}
</div>
</div>
)}
</div>
</div>
</>
</div>
) : (
<div className="flex-1 flex flex-col items-center justify-center text-muted-foreground">
<div className="w-16 h-16 rounded-2xl bg-white/5 flex items-center justify-center mb-4">
<Zap className="h-8 w-8 opacity-50" />
</div>
<p className="text-lg font-medium"></p>
<p className="text-sm opacity-60"></p>
<p className="text-lg font-medium"></p>
<p className="text-sm opacity-60"></p>
</div>
)}
</div>
{/* Delete Confirmation Dialog */}
{/* Delete Confirmation */}
<Dialog
isOpen={!!deleteId}
onClose={() => setDeleteId(null)}
isOpen={isDeleteModalOpen}
onClose={() => setIsDeleteModalOpen(false)}
title="确认删除"
footer={
<>
<Button variant="ghost" onClick={() => setDeleteId(null)}></Button>
<Button variant="destructive" onClick={confirmDelete}></Button>
</>
<>
<Button variant="ghost" onClick={() => setIsDeleteModalOpen(false)}></Button>
<Button variant="destructive" onClick={confirmDelete}></Button>
</>
}
>
<div className="flex items-center space-x-4">
<div className="p-3 bg-destructive/10 rounded-full">
<AlertTriangle className="h-6 w-6 text-destructive" />
</div>
<div>
<p className="text-sm text-foreground">
</p>
</div>
<div className="p-3 bg-destructive/10 rounded-full">
<AlertTriangle className="h-6 w-6 text-destructive" />
</div>
<div>
<p className="text-sm text-white"></p>
<p className="text-xs text-muted-foreground mt-1">
: {testAssistants.find(t => t.id === deleteId)?.name}
</p>
</div>
</div>
</Dialog>
</div>