适合人群:具备 Vue 基础,希望接入 AI 文本/图片与流式输出的开发者或技术爱好者。
建议先看:实践三(RESTful API 设计原则)、实践五(接口监控与限流)。
你将收获:SSE 两种实现、Axios 标准化封装、vite 代理配置与常见坑。

创建项目

npm create vue@latest

实现文本聊天页面

  • 目标:实现基础的文本输入、发送与消息列表展示。
  • 要点:输入框与发送按钮、消息时间顺序、滚动定位到最新消息。
  • 组件建议:ChatInput(输入区)、ChatList(消息区)。

实现图片

  • 目标:支持根据文本生成图片并展示缩略图。
  • 要点:提交参数(prompt、size、数量)、生成态 loading、失败重试、下载按钮。
  • 组件建议:ImageGenerateFormImageGrid

实现文本聊天SSE的方式

sse 前端实现方式

第一种 EventSource

const url = 
    "/api/ai/chat/stream?platform=openai&model=gpt-4o&message=" +
    encodeURIComponent(message);
  const sse = new EventSource(url);

  const handleDelta = (event: MessageEvent) => {
    const data = (event as MessageEvent).data;
  };

  const handleDone = () => {
    sse.close();
  };

  // Some servers don't set a named event; treat default messages as delta
  sse.onmessage = handleDelta;

  sse.addEventListener("delta", handleDelta as EventListener);
  sse.addEventListener("done", handleDone as EventListener);

  sse.onerror = (err) => {
    console.error("SSE error", err);
    sse.close();
  };

第二种 使用自带的fetch

  const res = await fetch("/api/ai/chat/stream", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ platform: "openai", model: "gpt-4o", message }),
  });
  if (!res.ok || !res.body) throw new Error(`请求失败: ${res.status}`);

  const reader = res.body.getReader();
  const decoder = new TextDecoder("utf-8");
  let buffer = "";

  while (true) {
    const { value, done } = await reader.read();
    if (done) break;
    buffer += decoder.decode(value, { stream: true });
    // Try to parse as SSE events: data: {json}\n\n
    let sepIndex: number;
    while ((sepIndex = buffer.indexOf("\n\n")) !== -1) {
      const rawEvent = buffer.slice(0, sepIndex);
      buffer = buffer.slice(sepIndex + 2);

      const dataLines = rawEvent
        .split("\n")
        .filter((l) => l.startsWith("data:"))
        .map((l) => l.replace(/^data:\s?/, ""))
        .join("");
      console.log("dataLines", dataLines);
      if (!dataLines) continue;

      consumeChunk(dataLines, assistantId);
    }

    // Fallback: handle NDJSON lines if server doesn't use blank-line separators
    // let lineIndex: number;
    // while ((lineIndex = buffer.indexOf("\n")) !== -1) {
    //   const rawLine = buffer.slice(0, lineIndex).trim();
    //   buffer = buffer.slice(lineIndex + 1);
    //   if (!rawLine) continue;
    //   if (rawLine.startsWith("data:")) {
    //     consumeChunk(rawLine.replace(/^data:\s?/, ""), assistantId);
    //   } else {
    //     consumeChunk(rawLine, assistantId);
    //   }
    // }

增加对话框

  • 目标:多会话管理,支持创建、重命名、删除与切换。
  • 要点:侧边栏会话列表、高亮当前会话、未读/进行中状态提示。
  • 数据:本地 localStorage 或后端存储会话元信息与消息。

API 调用信息

  • 目标:在界面中透明展示平台、模型、剩余次数/有效期等关键信息。
  • 要点:统一从 /api/user/quota 拉取;错误时给出可读提示(如未登录/已过期)。
  • 展示:头部条或设置抽屉中展示,避免打断主要流程。

API升级

  • 目标:后端接口版本/平台能力变化时,前端具备平滑升级能力。
  • 要点:通过配置层映射平台与模型;在切换平台时热更新可用模型列表。
  • 回退:接口报错时回退到上一个可用版本并提示用户。

登录与注册

  • 目标:支持注册/登录/登出与 Token 管理。
  • 要点:表单校验、登录态持久化(短期 Token 或 HttpOnly Cookie)、过期刷新。
  • 交互:失败清晰提示(401 引导重新登录)、成功跳转到最近会话。

axios

/**
 * Axios HTTP 封装:内置拦截器、Token 注入与类型友好的请求助手。
 * baseURL 来自 VITE_API_BASE_URL,未配置则回退为 '/api'。
 */
import axios, { type AxiosInstance, type AxiosRequestConfig, type AxiosResponse } from 'axios'

/**
 * 统一的接口返回结构(兼容常见服务约定):
 * - success:显式标记是否成功
 * - code:业务状态码(0/200 视为成功)
 * - data:实际数据载荷
 * - message/msg:人类可读的提示
 */
export interface ApiResponse<T = any> {
  code?: number | string
  data: T
  message?: string
  msg?: string
  success?: boolean
}

/** 所有请求的默认基础路径。 */
const baseURL =  '/api'

/** 预配置的 Axios 实例。 */
const http: AxiosInstance = axios.create({
  baseURL,
  timeout: 15000,
  withCredentials: false,
})

/**
 * 请求拦截器:
 * - 从 localStorage.token 注入 Authorization Bearer 令牌(若请求头未显式设置)。
 */
http.interceptors.request.use(
  (config) => {
    const token = localStorage.getItem('token')
    if (token && config.headers) {
      const hasAuth =
        typeof config.headers.Authorization === 'string' && config.headers.Authorization.length > 0
      if (!hasAuth) {
        config.headers.Authorization = `Bearer ${token}`
      }
    }
    return config
  },
  (error) => Promise.reject(error),
)

/** 从响应载荷中提取可读消息(若存在)。 */
function extractMessage(payload: unknown): string | undefined {
  if (!payload || typeof payload !== 'object') return undefined
  const obj = payload as Record<string, unknown>
  const candidates = ['message', 'msg', 'error', 'detail']
  for (const key of candidates) {
    const value = obj[key]
    if (typeof value === 'string' && value.trim().length > 0) {
      return value
    }
  }
  return undefined
}

/** 将 HTTP 状态码转换为本地化错误信息。 */
function httpStatusMessage(status: number): string {
  switch (status) {
    case 400:
      return '请求参数错误'
    case 401:
      return '未授权或登录已过期'
    case 403:
      return '无权限访问'
    case 404:
      return '资源未找到'
    case 408:
      return '请求超时'
    case 500:
      return '服务器错误'
    case 502:
      return '网关错误'
    case 503:
      return '服务不可用'
    case 504:
      return '网关超时'
    default:
      return `请求错误(${status})`
  }
}

/**
 * 响应拦截器:
 * - 当 success === true 或 code ∈ {0, 200} 时直接返回 data
 * - 若存在 code 且表示失败,则使用提取的 message 拒绝
 * - 统一网络/服务端错误为可读信息
 */
http.interceptors.response.use(
  (response: AxiosResponse<ApiResponse>) => {
    const { data, status } = response
    if (status >= 200 && status < 300) {
      if (data && typeof data === 'object') {
        const code = data.code as number | string | undefined
        const success = data.success
        if (success === true) return (data.data as any) ?? (data as any)
        if (code === 0 || code === 200 || code === '0' || code === '200') {
          return (data.data as any) ?? (data as any)
        }
        if (typeof code === 'undefined') {
          return data as any
        }
        const message = extractMessage(data) || '请求失败'
        return Promise.reject(new Error(message))
      }
      return response.data as any
    }
    return Promise.reject(new Error('网络错误'))
  },
  (error) => {
    if (axios.isCancel(error)) return Promise.reject(error)
    if (error?.response) {
      const status = error.response.status as number
      const msg = (extractMessage(error.response.data) || httpStatusMessage(status)) ?? '请求出错'
      return Promise.reject(new Error(msg))
    }
    const message = error?.message || '网络异常,请检查您的网络连接'
    return Promise.reject(new Error(message))
  },
)

/** 与 AxiosRequestConfig 等价,用于保留下方助手的范型。 */
type RequestConfig<T = any> = AxiosRequestConfig<T>

/** 低级请求助手,保留范型返回类型。 */
export function request<T = any>(config: RequestConfig): Promise<T> {
  return http.request<any, T>(config)
}

/** GET 快捷方法。 */
export const get = <T = any, R = T>(url: string, config?: RequestConfig) =>
  http.get<R>(url, config)

/** POST 快捷方法。 */
export const post = <T = any, R = T>(url: string, data?: T, config?: RequestConfig) =>
  http.post<R>(url, data, config)

/** PUT 快捷方法。 */
export const put = <T = any, R = T>(
  url: string,
  data?: T,
  config?: RequestConfig,
) => http.put<R>(url, data, config)

/** DELETE 快捷方法。 */
export const del = <T = any, R = T>(url: string, config?: RequestConfig) =>
  http.delete<R>(url, config)

export default http

代理

vite.config.ts

  server: {
    proxy: {
      '/api': {
        target: 'http://localhost:8080',
        changeOrigin: true,
        // 将 /api 前缀转发到后端时保留或重写
        // 若后端实际无 /api 前缀,请取消注释下一行:
        rewrite: (path) => path.replace(/^\/api/, ''),
      },
    },
  },

基本功能目标

1、登录,注册

2、api购买记录,有效期

3、文本聊天,文本聊天sse模式

4、图片生成

5、多平台切换

6、api调用次数统计 分1,7,30天,不同平台,不同api类型

7、多窗口

中级功能目标

1、聊天内容保存

2、支持模型选择

3、AI回复内容,支持 文本复制,图片下载

4、免费用户,每天只能调用三次

5、api购买实现,plus一个月调用100次,pro一个月调用1000次

高级功能

1、图片支持size设置,生成图片个数支持,1,2,3,4

2、图片进行本地存储,异步执行


最佳实践与常见问题

  • SSE 断线重连:EventSource 自带重连,手写 fetch 流需在网络断开时恢复并合并内容。
  • 错误提示:服务端返回 429/5xx 时统一友好文案,区分鉴权类与系统类;必要时弹窗引导降载。
  • Token 存储:避免将长效 Token 存在 localStorage,优先短时 Token 或 HttpOnly Cookie。
  • 代理重写:vite 代理 rewrite 与服务端路由前缀保持一致,避免 404/跨域问题。
  • 请求退避:对热点接口失败建议指数退避,避免前端风暴放大后端压力。
  • 图片下载:使用 a[download] 或 Blob URL;注意 CORS 与缓存策略。
Last modification:September 19, 2025
如果觉得我的文章对你有用,请随意赞赏
END
本文作者:
文章标题:软件工程实践八:Web 前端项目实战(SSE、Axios 与代理)
本文地址:https://blog.ybyq.wang/archives/1120.html
版权说明:若无注明,本文皆Xuan's blog原创,转载请保留文章出处。