Skip to content

第76天:智能客服系统-前端开发

学习目标

  • 掌握前端架构设计
  • 学习对话界面开发
  • 理解状态管理
  • 掌握API集成
  • 学习用户体验优化

前端架构设计

技术栈选择

typescript
interface TechStack {
  framework: string;
  uiLibrary: string;
  stateManagement: string;
  routing: string;
  httpClient: string;
  buildTool: string;
  testing: string;
}

const techStack: TechStack = {
  framework: "React 18",
  uiLibrary: "Ant Design 5",
  stateManagement: "Redux Toolkit",
  routing: "React Router 6",
  httpClient: "Axios",
  buildTool: "Vite",
  testing: "Jest + React Testing Library"
};

项目结构

src/
├── components/
│   ├── Chat/
│   │   ├── ChatContainer.tsx
│   │   ├── MessageList.tsx
│   │   ├── MessageInput.tsx
│   │   └── TypingIndicator.tsx
│   ├── Ticket/
│   │   ├── TicketList.tsx
│   │   ├── TicketDetail.tsx
│   │   └── TicketForm.tsx
│   └── Shared/
│       ├── Layout.tsx
│       ├── Header.tsx
│       └── Sidebar.tsx
├── pages/
│   ├── ChatPage.tsx
│   ├── TicketPage.tsx
│   └── KnowledgePage.tsx
├── store/
│   ├── chatSlice.ts
│   ├── ticketSlice.ts
│   └── index.ts
├── services/
│   ├── api.ts
│   ├── chatService.ts
│   └── ticketService.ts
├── hooks/
│   ├── useChat.ts
│   └── useTicket.ts
├── types/
│   ├── chat.ts
│   └── ticket.ts
└── utils/
    ├── helpers.ts
    └── constants.ts

对话界面开发

聊天容器组件

typescript
import React, { useState, useEffect } from 'react';
import { Layout, Card } from 'antd';
import MessageList from './MessageList';
import MessageInput from './MessageInput';
import TypingIndicator from './TypingIndicator';
import { useChat } from '../../hooks/useChat';
import { Message } from '../../types/chat';

const { Content } = Layout;

interface ChatContainerProps {
  sessionId?: string;
  userId?: string;
}

const ChatContainer: React.FC<ChatContainerProps> = ({
  sessionId,
  userId
}) => {
  const [isTyping, setIsTyping] = useState(false);
  
  const {
    messages,
    sendMessage,
    loading,
    error
  } = useChat(sessionId, userId);

  const handleSendMessage = async (content: string) => {
    setIsTyping(true);
    
    try {
      await sendMessage(content);
    } catch (err) {
      console.error('发送消息失败:', err);
    } finally {
      setIsTyping(false);
    }
  };

  return (
    <Layout style={{ height: '100vh' }}>
      <Content style={{ padding: '24px' }}>
        <Card 
          title="智能客服" 
          style={{ height: '100%' }}
          bodyStyle={{ 
            display: 'flex',
            flexDirection: 'column',
            height: 'calc(100% - 57px)'
          }}
        >
          <div style={{ flex: 1, overflow: 'auto', marginBottom: '16px' }}>
            <MessageList messages={messages} />
          </div>
          
          {isTyping && <TypingIndicator />}
          
          <MessageInput 
            onSend={handleSendMessage}
            disabled={loading || isTyping}
          />
        </Card>
      </Content>
    </Layout>
  );
};

export default ChatContainer;

消息列表组件

typescript
import React from 'react';
import { List, Avatar, Tag, Space } from 'antd';
import { UserOutlined, RobotOutlined } from '@ant-design/icons';
import { Message } from '../../types/chat';

interface MessageListProps {
  messages: Message[];
}

const MessageList: React.FC<MessageListProps> = ({ messages }) => {
  const renderMessage = (message: Message) => {
    const isUser = message.role === 'user';
    
    return (
      <div
        key={message.id}
        style={{
          display: 'flex',
          justifyContent: isUser ? 'flex-end' : 'flex-start',
          marginBottom: '16px'
        }}
      >
        <Space direction="vertical" size={4}>
          <div
            style={{
              display: 'flex',
              alignItems: 'center',
              gap: '8px'
            }}
          >
            {isUser ? (
              <>
                <span style={{ fontSize: '12px', color: '#999' }}>
                  {new Date(message.timestamp).toLocaleTimeString()}
                </span>
                <Avatar icon={<UserOutlined />} />
              </>
            ) : (
              <>
                <Avatar icon={<RobotOutlined />} />
                <span style={{ fontSize: '12px', color: '#999' }}>
                  {new Date(message.timestamp).toLocaleTimeString()}
                </span>
              </>
            )}
          </div>
          
          <div
            style={{
              maxWidth: '70%',
              padding: '12px 16px',
              borderRadius: '8px',
              backgroundColor: isUser ? '#1890ff' : '#f0f0f0',
              color: isUser ? '#fff' : '#333',
              wordBreak: 'break-word'
            }}
          >
            {message.content}
          </div>
          
          {message.intent && (
            <Tag color="blue" style={{ fontSize: '12px' }}>
              意图: {message.intent}
            </Tag>
          )}
          
          {message.sentiment && (
            <Tag 
              color={message.sentiment === 'positive' ? 'green' : 
                     message.sentiment === 'negative' ? 'red' : 'default'}
              style={{ fontSize: '12px' }}
            >
              情感: {message.sentiment}
            </Tag>
          )}
        </Space>
      </div>
    );
  };

  return (
    <div>
      {messages.length === 0 ? (
        <div 
          style={{ 
            textAlign: 'center', 
            color: '#999',
            marginTop: '100px'
          }}
        >
          <p>开始对话吧!</p>
        </div>
      ) : (
        messages.map(renderMessage)
      )}
    </div>
  );
};

export default MessageList;

消息输入组件

typescript
import React, { useState, useRef, useEffect } from 'react';
import { Input, Button, Space, message } from 'antd';
import { SendOutlined, PaperClipOutlined } from '@ant-design/icons';

const { TextArea } = Input;

interface MessageInputProps {
  onSend: (content: string) => Promise<void>;
  disabled?: boolean;
}

const MessageInput: React.FC<MessageInputProps> = ({
  onSend,
  disabled = false
}) => {
  const [content, setContent] = useState('');
  const [isSending, setIsSending] = useState(false);
  const textAreaRef = useRef<any>(null);

  useEffect(() => {
    if (textAreaRef.current) {
      textAreaRef.current.focus();
    }
  }, []);

  const handleSend = async () => {
    if (!content.trim()) {
      message.warning('请输入消息内容');
      return;
    }

    setIsSending(true);
    
    try {
      await onSend(content);
      setContent('');
    } catch (error) {
      message.error('发送失败,请重试');
    } finally {
      setIsSending(false);
    }
  };

  const handleKeyPress = (e: React.KeyboardEvent) => {
    if (e.key === 'Enter' && !e.shiftKey) {
      e.preventDefault();
      handleSend();
    }
  };

  const handleFileUpload = () => {
    message.info('文件上传功能开发中');
  };

  return (
    <div style={{ display: 'flex', gap: '8px' }}>
      <Button
        icon={<PaperClipOutlined />}
        onClick={handleFileUpload}
        disabled={disabled || isSending}
      />
      
      <TextArea
        ref={textAreaRef}
        value={content}
        onChange={(e) => setContent(e.target.value)}
        onKeyPress={handleKeyPress}
        placeholder="输入消息,按Enter发送,Shift+Enter换行"
        autoSize={{ minRows: 1, maxRows: 4 }}
        disabled={disabled || isSending}
        style={{ flex: 1 }}
      />
      
      <Button
        type="primary"
        icon={<SendOutlined />}
        onClick={handleSend}
        disabled={disabled || isSending || !content.trim()}
        loading={isSending}
      >
        发送
      </Button>
    </div>
  );
};

export default MessageInput;

打字指示器

typescript
import React from 'react';
import { Space } from 'antd';

const TypingIndicator: React.FC = () => {
  return (
    <div style={{ padding: '12px 16px' }}>
      <Space size={4}>
        <span 
          style={{
            width: '8px',
            height: '8px',
            borderRadius: '50%',
            backgroundColor: '#1890ff',
            animation: 'bounce 1.4s infinite ease-in-out both'
          }}
        />
        <span 
          style={{
            width: '8px',
            height: '8px',
            borderRadius: '50%',
            backgroundColor: '#1890ff',
            animation: 'bounce 1.4s infinite ease-in-out both 0.16s'
          }}
        />
        <span 
          style={{
            width: '8px',
            height: '8px',
            borderRadius: '50%',
            backgroundColor: '#1890ff',
            animation: 'bounce 1.4s infinite ease-in-out both 0.32s'
          }}
        />
      </Space>
      
      <style>{`
        @keyframes bounce {
          0%, 80%, 100% {
            transform: scale(0);
          }
          40% {
            transform: scale(1);
          }
        }
      `}</style>
    </div>
  );
};

export default TypingIndicator;

状态管理

Redux Store配置

typescript
import { configureStore } from '@reduxjs/toolkit';
import chatReducer from './chatSlice';
import ticketReducer from './ticketSlice';

export const store = configureStore({
  reducer: {
    chat: chatReducer,
    ticket: ticketReducer
  },
  middleware: (getDefaultMiddleware) =>
    getDefaultMiddleware({
      serializableCheck: {
        ignoredActions: ['chat/addMessage/fulfilled'],
        ignoredPaths: ['chat.messages']
      }
    })
});

export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;

Chat Slice

typescript
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
import { Message } from '../types/chat';
import { chatService } from '../services/chatService';

interface ChatState {
  messages: Message[];
  sessionId: string | null;
  loading: boolean;
  error: string | null;
  isTyping: boolean;
}

const initialState: ChatState = {
  messages: [],
  sessionId: null,
  loading: false,
  error: null,
  isTyping: false
};

export const sendMessage = createAsyncThunk(
  'chat/sendMessage',
  async ({ content, sessionId, userId }: { 
    content: string; 
    sessionId?: string; 
    userId?: string 
  }) => {
    const response = await chatService.sendMessage({
      message: content,
      session_id: sessionId,
      user_id: userId
    });
    
    return response;
  }
);

export const getConversationHistory = createAsyncThunk(
  'chat/getConversationHistory',
  async (sessionId: string) => {
    const response = await chatService.getConversationHistory(sessionId);
    return response;
  }
);

const chatSlice = createSlice({
  name: 'chat',
  initialState,
  reducers: {
    setSessionId: (state, action: PayloadAction<string>) => {
      state.sessionId = action.payload;
    },
    clearMessages: (state) => {
      state.messages = [];
      state.sessionId = null;
    },
    setTyping: (state, action: PayloadAction<boolean>) => {
      state.isTyping = action.payload;
    }
  },
  extraReducers: (builder) => {
    builder
      .addCase(sendMessage.pending, (state) => {
        state.loading = true;
        state.error = null;
      })
      .addCase(sendMessage.fulfilled, (state, action) => {
        state.loading = false;
        state.sessionId = action.payload.session_id;
        
        const userMessage: Message = {
          id: `msg_${Date.now()}_user`,
          role: 'user',
          content: action.meta.arg.content,
          timestamp: new Date().toISOString()
        };
        
        const assistantMessage: Message = {
          id: action.payload.message_id,
          role: 'assistant',
          content: action.payload.response,
          timestamp: new Date().toISOString(),
          intent: action.payload.intent,
          sentiment: action.payload.sentiment
        };
        
        state.messages.push(userMessage, assistantMessage);
      })
      .addCase(sendMessage.rejected, (state, action) => {
        state.loading = false;
        state.error = action.error.message || '发送消息失败';
      })
      .addCase(getConversationHistory.fulfilled, (state, action) => {
        state.messages = action.payload;
      });
  }
});

export const { setSessionId, clearMessages, setTyping } = chatSlice.actions;
export default chatSlice.reducer;

Ticket Slice

typescript
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
import { Ticket } from '../types/ticket';
import { ticketService } from '../services/ticketService';

interface TicketState {
  tickets: Ticket[];
  currentTicket: Ticket | null;
  loading: boolean;
  error: string | null;
}

const initialState: TicketState = {
  tickets: [],
  currentTicket: null,
  loading: false,
  error: null
};

export const createTicket = createAsyncThunk(
  'ticket/createTicket',
  async (ticketData: {
    user_id: string;
    title: string;
    description: string;
    priority?: string;
    conversation_id?: string;
  }) => {
    const response = await ticketService.createTicket(ticketData);
    return response;
  }
);

export const getTickets = createAsyncThunk(
  'ticket/getTickets',
  async ({ userId, status }: { userId?: string; status?: string }) => {
    const response = await ticketService.getTickets(userId, status);
    return response;
  }
);

export const updateTicket = createAsyncThunk(
  'ticket/updateTicket',
  async ({ ticketId, updates }: { ticketId: string; updates: any }) => {
    const response = await ticketService.updateTicket(ticketId, updates);
    return response;
  }
);

const ticketSlice = createSlice({
  name: 'ticket',
  initialState,
  reducers: {
    setCurrentTicket: (state, action: PayloadAction<Ticket | null>) => {
      state.currentTicket = action.payload;
    },
    clearTickets: (state) => {
      state.tickets = [];
      state.currentTicket = null;
    }
  },
  extraReducers: (builder) => {
    builder
      .addCase(createTicket.fulfilled, (state, action) => {
        state.tickets.unshift(action.payload);
      })
      .addCase(getTickets.fulfilled, (state, action) => {
        state.tickets = action.payload;
      })
      .addCase(updateTicket.fulfilled, (state, action) => {
        const index = state.tickets.findIndex(
          t => t.id === action.payload.id
        );
        if (index !== -1) {
          state.tickets[index] = action.payload;
        }
        if (state.currentTicket?.id === action.payload.id) {
          state.currentTicket = action.payload;
        }
      });
  }
});

export const { setCurrentTicket, clearTickets } = ticketSlice.actions;
export default ticketSlice.reducer;

API集成

API服务

typescript
import axios, { AxiosInstance, AxiosError } from 'axios';

class APIService {
  private client: AxiosInstance;

  constructor(baseURL: string) {
    this.client = axios.create({
      baseURL,
      timeout: 30000,
      headers: {
        'Content-Type': 'application/json'
      }
    });

    this.client.interceptors.request.use(
      (config) => {
        const token = localStorage.getItem('token');
        if (token) {
          config.headers.Authorization = `Bearer ${token}`;
        }
        return config;
      },
      (error) => {
        return Promise.reject(error);
      }
    );

    this.client.interceptors.response.use(
      (response) => response,
      (error: AxiosError) => {
        if (error.response?.status === 401) {
          localStorage.removeItem('token');
          window.location.href = '/login';
        }
        return Promise.reject(error);
      }
    );
  }

  async get<T>(url: string, params?: any): Promise<T> {
    const response = await this.client.get<T>(url, { params });
    return response.data;
  }

  async post<T>(url: string, data?: any): Promise<T> {
    const response = await this.client.post<T>(url, data);
    return response.data;
  }

  async put<T>(url: string, data?: any): Promise<T> {
    const response = await this.client.put<T>(url, data);
    return response.data;
  }

  async delete<T>(url: string): Promise<T> {
    const response = await this.client.delete<T>(url);
    return response.data;
  }
}

export const apiService = new APIService(
  import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000/api'
);

Chat服务

typescript
import { apiService } from './api';
import { Message } from '../types/chat';

interface SendMessageRequest {
  message: string;
  session_id?: string;
  user_id?: string;
}

interface SendMessageResponse {
  session_id: string;
  message_id: string;
  response: string;
  intent?: string;
  sentiment?: string;
  confidence?: number;
}

export const chatService = {
  async sendMessage(request: SendMessageRequest): Promise<SendMessageResponse> {
    return apiService.post<SendMessageResponse>('/chat', request);
  },

  async getConversationHistory(sessionId: string): Promise<Message[]> {
    return apiService.get<Message[]>(`/chat/history/${sessionId}`);
  },

  async clearConversation(sessionId: string): Promise<void> {
    return apiService.delete<void>(`/chat/${sessionId}`);
  }
};

Ticket服务

typescript
import { apiService } from './api';
import { Ticket } from '../types/ticket';

interface CreateTicketRequest {
  user_id: string;
  title: string;
  description: string;
  priority?: string;
  conversation_id?: string;
}

export const ticketService = {
  async createTicket(request: CreateTicketRequest): Promise<Ticket> {
    return apiService.post<Ticket>('/tickets', request);
  },

  async getTickets(userId?: string, status?: string): Promise<Ticket[]> {
    const params: any = {};
    if (userId) params.user_id = userId;
    if (status) params.status = status;
    
    return apiService.get<Ticket[]>('/tickets', params);
  },

  async getTicket(ticketId: string): Promise<Ticket> {
    return apiService.get<Ticket>(`/tickets/${ticketId}`);
  },

  async updateTicket(ticketId: string, updates: any): Promise<Ticket> {
    return apiService.put<Ticket>(`/tickets/${ticketId}`, updates);
  },

  async deleteTicket(ticketId: string): Promise<void> {
    return apiService.delete<void>(`/tickets/${ticketId}`);
  }
};

实践练习

练习1:创建聊天组件

typescript
function createChatComponent() {
  const ChatContainer = () => {
    return (
      <div>
        <MessageList />
        <MessageInput />
      </div>
    );
  };
  
  return ChatContainer;
}

练习2:实现状态管理

typescript
function implementStateManagement() {
  const store = configureStore({
    reducer: {
      chat: chatReducer,
      ticket: ticketReducer
    }
  });
  
  return store;
}

练习3:集成API服务

typescript
function integrateAPIService() {
  const apiService = new APIService('http://localhost:8000/api');
  
  return apiService;
}

总结

本节我们学习了智能客服系统前端开发:

  1. 前端架构设计
  2. 对话界面开发
  3. 状态管理
  4. API集成

前端是用户与系统交互的重要界面。

参考资源