适合人群:具备 Vue 基础,希望接入 AI 文本/图片与流式输出的开发者或技术爱好者。
建议先看:实践三(RESTful API 设计原则)、实践五(接口监控与限流)。
你将收获:SSE 两种实现、Axios 标准化封装、vite 代理配置与常见坑。
创建项目
npm create vue@latest
实现文本聊天页面
- 目标:实现基础的文本输入、发送与消息列表展示。
- 要点:输入框与发送按钮、消息时间顺序、滚动定位到最新消息。
- 组件建议:
ChatInput
(输入区)、ChatList
(消息区)。
实现图片
- 目标:支持根据文本生成图片并展示缩略图。
- 要点:提交参数(prompt、size、数量)、生成态 loading、失败重试、下载按钮。
- 组件建议:
ImageGenerateForm
、ImageGrid
。
实现文本聊天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 与缓存策略。