Final Project / 12
Web UI 交互设计
CLI 适合开发调试,但面试诊断的用户不是开发者——他们是准备面试的候选人。
TUI 的问题很明显:
- 显示局限:诊断报告有表格、分数、对比视图,终端排版做不到直观
- 交互局限:上传文件、录音、模拟面试的即时反馈,CLI 体验很差
- 门槛高:装 Node.js、配环境变量、敲命令——90% 的目标用户到不了这一步
所以 Web UI 不是“锦上添花”,是让产品真正可用的必要条件。
技术选型
| 层 | 技术 | 理由 |
|---|---|---|
| 框架 | Next.js 14 (App Router) | RSC + API Routes + 部署一体化 |
| UI 库 | shadcn/ui + Tailwind CSS | 组件质量高、可定制、不臃肿 |
| 状态管理 | Zustand | 轻量、TypeScript 友好 |
| 实时通信 | Server-Sent Events (SSE) | 单向流式输出,比 WebSocket 简单 |
| 录音 | MediaRecorder API | 浏览器原生,无依赖 |
| 图表 | Recharts | 分数趋势、雷达图 |
| 部署 | Vercel(前端)+ Railway(后端) | 各自最佳实践 |
为什么不用 WebSocket?
诊断是单向流——服务端持续输出诊断进度和结果,客户端只在开始时发一次请求。SSE 恰好满足,且 Next.js API Routes 原生支持。
页面结构
/ 首页(上传入口 + 功能介绍)
/diagnose 诊断主页面(上传 → 实时诊断 → 报告)
/diagnose/[sessionId] 历史诊断报告查看
/mock 模拟面试页面
/history 诊断历史列表
/settings 配置页面(模型、STT、偏好)
核心交互流程
Flow 1:文字稿诊断
flowchart TB
subgraph "Step 1: 上传"
A1[粘贴文字稿] --> A2[或上传 .txt/.md 文件]
A2 --> A3[预览 + 自动检测格式]
end
subgraph "Step 2: 实时诊断"
B1[逐题流式输出诊断进度] --> B2[每题完成后即时展示分数]
B2 --> B3[进度条 + 当前题目高亮]
end
subgraph "Step 3: 报告"
C1[总览: 总分 + 雷达图] --> C2[分题详情: 折叠面板]
C2 --> C3[改进建议: 分层展示]
C3 --> C4[导出 / 分享]
end
A3 --> B1 --> C1
Flow 2:录音诊断
flowchart TB
subgraph "Step 1: 录音"
R1[上传音频文件]
R2[或浏览器直接录音]
R1 & R2 --> R3[音频波形预览]
end
subgraph "Step 2: 处理"
P1[STT 转写中...] --> P2[转写结果预览 + 人工校对]
P2 --> P3[说话人标注确认]
end
subgraph "Step 3: 诊断"
D1[内容 + 表达 + 语音三维并行] --> D2[语音热力图: 填充词/停顿标注在时间轴上]
end
R3 --> P1 --> D1
Flow 3:模拟面试
flowchart TB
subgraph "配置"
M1[选择维度 + 题数]
end
subgraph "面试中"
M2[Agent 提问] --> M3[用户文字输入 或 语音回答]
M3 --> M4[即时反馈: 得分 + 一句话点评]
M4 --> M5{还有题?}
M5 -->|是| M2
M5 -->|否| M6[总结报告]
end
M1 --> M2
页面设计
首页 /

诊断主页面 /diagnose
分为三个阶段,渐进式展开:
阶段 1:输入

阶段 2:实时诊断(SSE 流式更新)

阶段 3:报告

录音诊断 /diagnose?mode=audio
独特组件:

诊断报告中的语音维度展示:

模拟面试 /mock

组件设计
核心组件清单
components/
├── layout/
│ ├── Header.tsx # 导航栏
│ ├── Sidebar.tsx # 侧边历史列表
│ └── Footer.tsx
├── diagnose/
│ ├── TranscriptInput.tsx # 文字稿输入(TextArea + 文件上传)
│ ├── AudioUploader.tsx # 音频上传 + 波形预览
│ ├── AudioRecorder.tsx # 浏览器录音组件
│ ├── ProgressTracker.tsx # 实时诊断进度(SSE 驱动)
│ ├── QuestionCard.tsx # 单题诊断结果卡片
│ ├── ScoreRadar.tsx # 雷达图(四维/三维)
│ ├── CompareView.tsx # 用户答 vs 高手答对比
│ ├── ImprovementPlan.tsx # 分层改进建议
│ └── ReportExport.tsx # 导出/分享按钮
├── speech/
│ ├── WaveformView.tsx # 音频波形
│ ├── TimelineAnnotation.tsx # 时间轴标注(填充词/停顿)
│ └── SpeechMetrics.tsx # 语音指标卡片
├── mock/
│ ├── QuestionDisplay.tsx # 面试官提问展示
│ ├── AnswerInput.tsx # 用户回答(文字+语音)
│ ├── InstantFeedback.tsx # 即时反馈面板
│ └── MockSummary.tsx # 模拟面试总结
├── shared/
│ ├── ScoreBadge.tsx # 分数标签(颜色映射)
│ ├── DimensionBar.tsx # 维度得分条
│ ├── StreamText.tsx # 流式文字渲染
│ └── ConfirmDialog.tsx # 权限确认弹窗
└── history/
├── SessionList.tsx # 历史会话列表
└── TrendChart.tsx # 得分趋势折线图
关键组件实现
StreamText:流式文字渲染
诊断过程中 Agent 的输出是流式的(SSE),需要逐字渲染:
// components/shared/StreamText.tsx
'use client';
import { useEffect, useState } from 'react';
interface StreamTextProps {
url: string; // SSE endpoint
onComplete?: (text: string) => void;
}
export function StreamText({ url, onComplete }: StreamTextProps) {
const [text, setText] = useState('');
const [isStreaming, setIsStreaming] = useState(true);
useEffect(() => {
const eventSource = new EventSource(url);
eventSource.onmessage = (event) => {
const data = JSON.parse(event.data);
switch (data.type) {
case 'text_delta':
setText(prev => prev + data.content);
break;
case 'progress':
// 进度更新由父组件处理
break;
case 'done':
setIsStreaming(false);
onComplete?.(text);
eventSource.close();
break;
}
};
eventSource.onerror = () => {
setIsStreaming(false);
eventSource.close();
};
return () => eventSource.close();
}, [url]);
return (
<div className="whitespace-pre-wrap">
{text}
{isStreaming && <span className="animate-pulse">▊</span>}
</div>
);
}
ProgressTracker:实时诊断进度
// components/diagnose/ProgressTracker.tsx
'use client';
import { useEffect, useState } from 'react';
import { ScoreBadge } from '../shared/ScoreBadge';
interface QuestionProgress {
index: number;
question: string;
status: 'pending' | 'processing' | 'done';
score?: number;
}
export function ProgressTracker({ sessionId }: { sessionId: string }) {
const [questions, setQuestions] = useState<QuestionProgress[]>([]);
const [currentOutput, setCurrentOutput] = useState('');
useEffect(() => {
const es = new EventSource(`/api/diagnose/${sessionId}/stream`);
es.addEventListener('question_start', (e) => {
const data = JSON.parse(e.data);
setQuestions(prev => prev.map(q =>
q.index === data.index ? { ...q, status: 'processing' } : q
));
setCurrentOutput('');
});
es.addEventListener('question_done', (e) => {
const data = JSON.parse(e.data);
setQuestions(prev => prev.map(q =>
q.index === data.index ? { ...q, status: 'done', score: data.score } : q
));
});
es.addEventListener('text_delta', (e) => {
const data = JSON.parse(e.data);
setCurrentOutput(prev => prev + data.content);
});
es.addEventListener('init', (e) => {
const data = JSON.parse(e.data);
setQuestions(data.questions.map((q: any, i: number) => ({
index: i + 1,
question: q.question,
status: 'pending',
})));
});
return () => es.close();
}, [sessionId]);
const done = questions.filter(q => q.status === 'done').length;
const total = questions.length;
return (
<div className="space-y-4">
{/* 进度条 */}
<div className="flex items-center gap-3">
<div className="flex-1 h-2 bg-gray-200 rounded-full overflow-hidden">
<div
className="h-full bg-blue-500 transition-all duration-300"
style={{ width: `${total ? (done / total) * 100 : 0}%` }}
/>
</div>
<span className="text-sm text-gray-500">{done}/{total}</span>
</div>
{/* 题目列表 */}
<div className="space-y-2">
{questions.map(q => (
<div key={q.index} className="flex items-center gap-3 py-2 border-b">
<StatusIcon status={q.status} />
<span className="flex-1 text-sm truncate">
Q{q.index}: {q.question}
</span>
{q.score !== undefined && <ScoreBadge score={q.score} />}
</div>
))}
</div>
{/* 当前题目的实时输出 */}
{currentOutput && (
<div className="mt-4 p-3 bg-gray-50 rounded-lg text-sm">
<p className="text-gray-500 mb-1">实时诊断:</p>
<p className="whitespace-pre-wrap">{currentOutput}</p>
</div>
)}
</div>
);
}
AudioRecorder:浏览器录音
// components/diagnose/AudioRecorder.tsx
'use client';
import { useState, useRef } from 'react';
interface AudioRecorderProps {
onRecordingComplete: (blob: Blob) => void;
}
export function AudioRecorder({ onRecordingComplete }: AudioRecorderProps) {
const [isRecording, setIsRecording] = useState(false);
const [duration, setDuration] = useState(0);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const chunksRef = useRef<Blob[]>([]);
const timerRef = useRef<NodeJS.Timeout>();
async function startRecording() {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const mediaRecorder = new MediaRecorder(stream, { mimeType: 'audio/webm' });
mediaRecorderRef.current = mediaRecorder;
chunksRef.current = [];
mediaRecorder.ondataavailable = (e) => {
if (e.data.size > 0) chunksRef.current.push(e.data);
};
mediaRecorder.onstop = () => {
const blob = new Blob(chunksRef.current, { type: 'audio/webm' });
onRecordingComplete(blob);
stream.getTracks().forEach(track => track.stop());
};
mediaRecorder.start(1000); // 每秒一个 chunk
setIsRecording(true);
setDuration(0);
timerRef.current = setInterval(() => setDuration(d => d + 1), 1000);
}
function stopRecording() {
mediaRecorderRef.current?.stop();
setIsRecording(false);
clearInterval(timerRef.current);
}
return (
<div className="flex items-center gap-4">
<button
onClick={isRecording ? stopRecording : startRecording}
className={`w-12 h-12 rounded-full flex items-center justify-center
${isRecording ? 'bg-red-500 animate-pulse' : 'bg-blue-500 hover:bg-blue-600'}`}
>
{isRecording ? '■' : '●'}
</button>
{isRecording && (
<span className="text-sm text-gray-500">
录音中 {Math.floor(duration / 60)}:{String(duration % 60).padStart(2, '0')}
</span>
)}
</div>
);
}
后端 API 设计
API Routes (Next.js App Router)
app/api/
├── diagnose/
│ ├── route.ts POST: 创建诊断任务
│ └── [sessionId]/
│ ├── stream/route.ts GET: SSE 流式诊断输出
│ └── route.ts GET: 获取诊断报告
├── upload/
│ └── route.ts POST: 上传文件(文字稿/音频)
├── mock/
│ ├── start/route.ts POST: 开始模拟面试
│ └── answer/route.ts POST: 提交回答
├── sessions/
│ └── route.ts GET: 会话列表
└── settings/
└── route.ts GET/PUT: 用户配置
SSE 流式接口实现
// app/api/diagnose/[sessionId]/stream/route.ts
import { NextRequest } from 'next/server';
import { createApp } from '@/lib/app';
export async function GET(
req: NextRequest,
{ params }: { params: { sessionId: string } }
) {
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
const app = await createApp();
const session = app.getSession(params.sessionId);
if (!session) {
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'error', message: 'Session not found' })}\n\n`));
controller.close();
return;
}
// 发送初始化事件
controller.enqueue(encoder.encode(
`event: init\ndata: ${JSON.stringify({ questions: session.state.qaPairs })}\n\n`
));
// 注册进度回调
session.onProgress = (event) => {
controller.enqueue(encoder.encode(
`event: ${event.type}\ndata: ${JSON.stringify(event.data)}\n\n`
));
};
session.onTextDelta = (text) => {
controller.enqueue(encoder.encode(
`event: text_delta\ndata: ${JSON.stringify({ content: text })}\n\n`
));
};
// 开始诊断
try {
await app.runDiagnosis(session);
controller.enqueue(encoder.encode(
`event: done\ndata: ${JSON.stringify({ report: session.state.latestReport })}\n\n`
));
} catch (err) {
controller.enqueue(encoder.encode(
`event: error\ndata: ${JSON.stringify({ message: String(err) })}\n\n`
));
}
controller.close();
},
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
},
});
}
文件上传接口
// app/api/upload/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { writeFile } from 'fs/promises';
import { join } from 'path';
export async function POST(req: NextRequest) {
const formData = await req.formData();
const file = formData.get('file') as File;
if (!file) {
return NextResponse.json({ error: 'No file provided' }, { status: 400 });
}
// 验证文件类型和大小
const allowedTypes = ['text/plain', 'text/markdown', 'audio/mpeg', 'audio/wav', 'audio/webm', 'audio/mp4'];
if (!allowedTypes.some(t => file.type.startsWith(t.split('/')[0]))) {
return NextResponse.json({ error: 'Unsupported file type' }, { status: 400 });
}
const maxSize = 100 * 1024 * 1024; // 100MB
if (file.size > maxSize) {
return NextResponse.json({ error: 'File too large (max 100MB)' }, { status: 400 });
}
// 保存文件
const buffer = Buffer.from(await file.arrayBuffer());
const filename = `${Date.now()}-${file.name}`;
const uploadDir = join(process.cwd(), 'uploads');
const filePath = join(uploadDir, filename);
await writeFile(filePath, buffer);
// 自动检测类型
const isAudio = file.type.startsWith('audio/');
const fileType = isAudio ? 'audio' : 'transcript';
return NextResponse.json({
path: filePath,
type: fileType,
size: file.size,
name: file.name,
});
}
状态管理(Zustand)
// lib/store.ts
import { create } from 'zustand';
interface DiagnosisState {
// 当前诊断
sessionId: string | null;
status: 'idle' | 'uploading' | 'diagnosing' | 'done' | 'error';
progress: { done: number; total: number };
questions: QuestionProgress[];
report: DiagnosisReport | null;
currentOutput: string;
// Actions
startDiagnosis: (sessionId: string, questions: any[]) => void;
updateQuestion: (index: number, update: Partial<QuestionProgress>) => void;
appendOutput: (text: string) => void;
setReport: (report: DiagnosisReport) => void;
reset: () => void;
}
export const useDiagnosisStore = create<DiagnosisState>((set) => ({
sessionId: null,
status: 'idle',
progress: { done: 0, total: 0 },
questions: [],
report: null,
currentOutput: '',
startDiagnosis: (sessionId, questions) => set({
sessionId,
status: 'diagnosing',
progress: { done: 0, total: questions.length },
questions: questions.map((q, i) => ({ index: i + 1, question: q.question, status: 'pending' })),
report: null,
currentOutput: '',
}),
updateQuestion: (index, update) => set((state) => ({
questions: state.questions.map(q => q.index === index ? { ...q, ...update } : q),
progress: { ...state.progress, done: state.questions.filter(q => q.status === 'done').length + (update.status === 'done' ? 1 : 0) },
})),
appendOutput: (text) => set((state) => ({
currentOutput: state.currentOutput + text,
})),
setReport: (report) => set({ report, status: 'done' }),
reset: () => set({
sessionId: null, status: 'idle', progress: { done: 0, total: 0 },
questions: [], report: null, currentOutput: '',
}),
}));
权限确认的 Web 化
CLI 里的 human-in-the-loop 用 readline,Web 里用弹窗:
// components/shared/ConfirmDialog.tsx
'use client';
interface ConfirmDialogProps {
open: boolean;
level: 'medium' | 'high';
toolName: string;
reason: string;
onConfirm: () => void;
onDeny: () => void;
}
export function ConfirmDialog({ open, level, toolName, reason, onConfirm, onDeny }: ConfirmDialogProps) {
if (!open) return null;
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 max-w-md shadow-xl">
<div className="flex items-center gap-2 mb-3">
<span className={`px-2 py-0.5 rounded text-xs font-medium
${level === 'high' ? 'bg-red-100 text-red-700' : 'bg-yellow-100 text-yellow-700'}`}>
{level.toUpperCase()}
</span>
<h3 className="font-semibold">权限确认</h3>
</div>
<p className="text-sm text-gray-600 mb-2">操作: {toolName}</p>
<p className="text-sm text-gray-500 mb-4">{reason}</p>
<div className="flex justify-end gap-3">
<button onClick={onDeny} className="px-4 py-2 text-sm text-gray-600 hover:bg-gray-100 rounded">
拒绝
</button>
<button onClick={onConfirm} className="px-4 py-2 text-sm text-white bg-blue-500 hover:bg-blue-600 rounded">
允许
</button>
</div>
</div>
</div>
);
}
响应式设计
面试复习经常在手机上做(通勤路上看报告、睡前练两题),必须移动端友好:
断点策略:
- mobile (<768px): 单列布局,折叠面板,底部导航
- tablet (768-1024px): 双栏(题目列表 + 详情)
- desktop (>1024px): 三栏(历史侧栏 + 主区域 + 参考答案)
移动端优化:
- 模拟面试: 全屏沉浸式
- 语音录入: 大按钮 + 震动反馈
- 报告: 卡片式滑动浏览
- 对比视图: 上下堆叠(而非左右)
前后端通信时序
sequenceDiagram
participant Browser
participant NextAPI as Next.js API
participant Agent as Agent Backend
Browser->>NextAPI: POST /api/upload (file)
NextAPI-->>Browser: { path, type, sessionId }
Browser->>NextAPI: POST /api/diagnose (sessionId)
NextAPI->>Agent: create session + start
Browser->>NextAPI: GET /api/diagnose/:id/stream (SSE)
NextAPI->>Agent: subscribe to events
loop 每题诊断
Agent-->>NextAPI: event: question_start
NextAPI-->>Browser: SSE: question_start
Agent-->>NextAPI: event: text_delta (多次)
NextAPI-->>Browser: SSE: text_delta
Agent-->>NextAPI: event: question_done
NextAPI-->>Browser: SSE: question_done
end
Agent-->>NextAPI: event: report_ready
NextAPI-->>Browser: SSE: done + report
Browser->>Browser: 渲染完整报告
小结
- Web UI 不是可选项,是面试诊断产品可用的必要条件
- Next.js App Router + SSE 实现流式诊断实时反馈
- 三个核心流程:文字稿诊断(粘贴→流式→报告)、录音诊断(上传→转写→校对→诊断)、模拟面试(提问→回答→即时反馈)
- 组件化设计:ProgressTracker / StreamText / AudioRecorder / CompareView 等 20+ 组件
- 状态管理用 Zustand,SSE 驱动 UI 更新
- 响应式设计:移动端也能用(复习报告、练面试题)
- 权限确认从 CLI readline 升级为 Web 弹窗
- 后端 API 复用已有的 Agent Harness,只需加一层 HTTP 入口
下一篇建议继续看:
- 回到 Final Project 首页 查看完整文档目录