diff --git a/.cursor/rules/aiclient-overview.mdc b/.cursor/rules/aiclient-overview.mdc index d5cba11..d13ffab 100644 --- a/.cursor/rules/aiclient-overview.mdc +++ b/.cursor/rules/aiclient-overview.mdc @@ -32,6 +32,62 @@ src-tauri/ # Tauri Rust 与配置 - `npm run tauri dev` — 桌面开发 - `npm run build` / `npm run tauri build` — 构建(前端无 `tsc` 步骤) +## 后端 API 约定(pythonbackend) + +前端通过 HTTP 对接 `f:\projects\pythonbackend`(FastAPI,默认 `http://127.0.0.1:8001`)。业务接口前缀 `/api/v1`。 + +### 统一响应 `ApiResponse` + +所有 JSON 响应使用同一信封(与 `app/schemas/common.py` 一致): + +| 字段 | 类型 | 说明 | +|------|------|------| +| `ok` | `boolean` | `true` 表示业务成功,`false` 表示失败(如校验、鉴权、业务错误) | +| `message` | `string` | 提示文案,默认可为空;失败时展示给用户 | +| `data` | `T \| null` | 成功时的载荷;失败时通常为 `null` 或省略 | + +前端处理模式: + +```js +const body = await res.json(); +if (!body.ok) { + // 用 body.message 提示用户 + return { ok: false, message: body.message }; +} +// 使用 body.data +``` + +- 业务失败(如用户名已存在)仍返回 **HTTP 200**,以 `ok: false` 区分,勿仅依赖状态码。 +- 未认证访问受保护接口返回 **HTTP 401**(FastAPI 标准错误体,非 `ApiResponse` 信封)。 + +### 认证 + +- 登录/注册成功后,`data` 为 `TokenResponse`: + - `access_token`:JWT,前端持久化(如 localStorage `aiclient_token`) + - `token_type`:固定 `"bearer"` + - `user`:`{ id, username }` +- 需登录的请求请求头:`Authorization: Bearer ` +- 登出:`POST /api/v1/auth/logout`,带同上 Authorization;成功 `ok: true`,`message`: `"已退出登录"` + +### 认证相关端点 + +| 方法 | 路径 | 请求体 | `data`(成功时) | +|------|------|--------|------------------| +| POST | `/api/v1/auth/register` | `{ username, password, confirmPassword }` | `TokenResponse` | +| POST | `/api/v1/auth/login` | `{ username, password }` | `TokenResponse` | +| POST | `/api/v1/auth/logout` | 无 | `null` | +| GET | `/api/v1/auth/me` | 无 | `{ id, username }` | + +- 注册请求体字段名与前端表单一致:`confirmPassword`(camelCase);其余 API JSON 字段为 **snake_case**(如 `access_token`)。 +- 密码规则与本地逻辑对齐:至少 6 位;用户名 trim 后非空。 + + +### 前端对接注意 + +- Pinia `auth` store 的 `login` / `register` 可逐步改为调用上述 API,保留 `{ ok, message }` 与视图层一致。 +- 开发时后端需配置 CORS 包含 `http://localhost:1420`(见 pythonbackend `.env` 的 `CORS_ORIGINS`)。 +- 开发时 Vite 将 `/api` 代理到 `http://127.0.0.1:8001`(见 `vite.config.js`);API 基址默认 `/api/v1`,生产可设 `VITE_API_BASE_URL`。 + ## 通用原则 - 用户界面文案默认使用简体中文 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..98240c3 --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +# 开发默认走 Vite 代理 /api -> http://127.0.0.1:8001 +# 生产或直连后端时可设为完整地址,例如: +# VITE_API_BASE_URL=http://127.0.0.1:8001/api/v1 diff --git a/src/api/auth.js b/src/api/auth.js new file mode 100644 index 0000000..ae659ff --- /dev/null +++ b/src/api/auth.js @@ -0,0 +1,38 @@ +import { apiRequest } from "./client.js"; + +/** + * @param {{ username: string, password: string, confirmPassword: string }} payload + */ +export function registerApi(payload) { + return apiRequest("/auth/register", { + method: "POST", + body: JSON.stringify({ + username: payload.username, + password: payload.password, + confirmPassword: payload.confirmPassword, + }), + }); +} + +/** + * @param {{ username: string, password: string }} payload + */ +export function loginApi(payload) { + return apiRequest("/auth/login", { + method: "POST", + body: JSON.stringify({ + username: payload.username, + password: payload.password, + }), + }); +} + +/** @param {string} token */ +export function logoutApi(token) { + return apiRequest("/auth/logout", { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + }, + }); +} diff --git a/src/api/client.js b/src/api/client.js new file mode 100644 index 0000000..f18108b --- /dev/null +++ b/src/api/client.js @@ -0,0 +1,62 @@ +/** API 基址:开发走 Vite 代理 `/api`;生产可设 VITE_API_BASE_URL */ +const API_BASE = import.meta.env.VITE_API_BASE_URL || "/api/v1"; + +function formatValidationDetail(detail) { + if (!Array.isArray(detail)) { + return "请求参数无效"; + } + const first = detail[0]; + if (first?.msg) { + return String(first.msg); + } + return "请求参数无效"; +} + +/** + * @param {string} path - 相对路径,如 `/auth/register` + * @param {RequestInit} options + * @returns {Promise<{ ok: boolean, message: string, data?: unknown }>} + */ +export async function apiRequest(path, options = {}) { + const url = `${API_BASE}${path}`; + const headers = { + "Content-Type": "application/json", + ...options.headers, + }; + + let res; + try { + res = await fetch(url, { ...options, headers }); + } catch { + return { ok: false, message: "无法连接服务器,请确认后端已启动" }; + } + + let body = null; + try { + body = await res.json(); + } catch { + body = null; + } + + if (res.status === 422) { + return { + ok: false, + message: formatValidationDetail(body?.detail), + }; + } + + if (body && typeof body.ok === "boolean") { + return body; + } + + if (res.status === 401) { + return { ok: false, message: "未登录或登录已过期" }; + } + + return { + ok: false, + message: body?.message || `请求失败(${res.status})`, + }; +} + +export { API_BASE }; diff --git a/src/stores/auth.js b/src/stores/auth.js index 00b67d7..c5cf517 100644 --- a/src/stores/auth.js +++ b/src/stores/auth.js @@ -1,24 +1,25 @@ import { defineStore } from "pinia"; +import { loginApi, logoutApi, registerApi } from "../api/auth.js"; -const USERS_KEY = "aiclient_users"; const TOKEN_KEY = "aiclient_token"; const USER_KEY = "aiclient_current_user"; -function loadUsers() { - try { - const raw = localStorage.getItem(USERS_KEY); - return raw ? JSON.parse(raw) : []; - } catch { - return []; +function applyTokenResponse(store, res, fallbackMessage) { + if (!res.ok) { + return { ok: false, message: res.message || fallbackMessage }; } -} -function saveUsers(users) { - localStorage.setItem(USERS_KEY, JSON.stringify(users)); -} + const data = res.data; + if (!data?.access_token || !data?.user) { + return { ok: false, message: "服务器返回数据异常" }; + } -function createToken(username) { - return btoa(`${username}:${Date.now()}`); + store.setSession(data.access_token, { + id: data.user.id, + username: data.user.username, + }); + + return { ok: true, message: res.message || fallbackMessage }; } export const useAuthStore = defineStore("auth", { @@ -39,7 +40,21 @@ export const useAuthStore = defineStore("auth", { }, actions: { - register({ username, password, confirmPassword }) { + setSession(accessToken, user) { + this.token = accessToken; + this.currentUser = user; + localStorage.setItem(TOKEN_KEY, accessToken); + localStorage.setItem(USER_KEY, JSON.stringify(user)); + }, + + clearSession() { + this.token = ""; + this.currentUser = null; + localStorage.removeItem(TOKEN_KEY); + localStorage.removeItem(USER_KEY); + }, + + async register({ username, password, confirmPassword }) { const name = username?.trim(); if (!name || !password) { return { ok: false, message: "用户名和密码不能为空" }; @@ -51,51 +66,36 @@ export const useAuthStore = defineStore("auth", { return { ok: false, message: "密码长度至少为 6 位" }; } - const users = loadUsers(); - if (users.some((u) => u.username === name)) { - return { ok: false, message: "用户名已存在" }; - } + const res = await registerApi({ + username: name, + password, + confirmPassword, + }); - users.push({ username: name, password }); - saveUsers(users); - - return this.login({ username: name, password }); + return applyTokenResponse(this, res, "注册成功"); }, - login({ username, password }) { + async login({ username, password }) { const name = username?.trim(); if (!name || !password) { return { ok: false, message: "用户名和密码不能为空" }; } - const users = loadUsers(); - const user = users.find((u) => u.username === name); - if (!user || user.password !== password) { - return { ok: false, message: "用户名或密码错误" }; + const res = await loginApi({ + username: name, + password, + }); + + return applyTokenResponse(this, res, "登录成功"); + }, + + async logout() { + const token = this.token; + if (token) { + await logoutApi(token); } - - const token = createToken(name); - const currentUser = { username: name }; - - this.token = token; - this.currentUser = currentUser; - localStorage.setItem(TOKEN_KEY, token); - localStorage.setItem(USER_KEY, JSON.stringify(currentUser)); - - return { ok: true, message: "登录成功" }; - }, - - logout() { - this.token = ""; - this.currentUser = null; - localStorage.removeItem(TOKEN_KEY); - localStorage.removeItem(USER_KEY); + this.clearSession(); return { ok: true, message: "已退出登录" }; }, - - // 预留:后续对接 Tauri invoke / HTTP API - async loginWithApi(_credentials) { - return { ok: false, message: "API 登录尚未实现" }; - }, }, }); diff --git a/src/views/HomeView.vue b/src/views/HomeView.vue index 3b41ec8..7efe93c 100644 --- a/src/views/HomeView.vue +++ b/src/views/HomeView.vue @@ -5,8 +5,8 @@ import { useAuthStore } from "../stores/auth"; const router = useRouter(); const auth = useAuthStore(); -function onLogout() { - auth.logout(); +async function onLogout() { + await auth.logout(); router.push({ name: "login" }); } diff --git a/src/views/LoginView.vue b/src/views/LoginView.vue index 19f3c0d..02373ec 100644 --- a/src/views/LoginView.vue +++ b/src/views/LoginView.vue @@ -16,7 +16,7 @@ async function onSubmit() { feedback.value = { severity: "", message: "" }; loading.value = true; - const result = auth.login({ + const result = await auth.login({ username: username.value, password: password.value, }); diff --git a/src/views/RegisterView.vue b/src/views/RegisterView.vue index b4b956f..e1cf409 100644 --- a/src/views/RegisterView.vue +++ b/src/views/RegisterView.vue @@ -17,7 +17,7 @@ async function onSubmit() { feedback.value = { severity: "", message: "" }; loading.value = true; - const result = auth.register({ + const result = await auth.register({ username: username.value, password: password.value, confirmPassword: confirmPassword.value, diff --git a/vite.config.js b/vite.config.js index b3ef168..ddfdf57 100644 --- a/vite.config.js +++ b/vite.config.js @@ -11,6 +11,12 @@ export default defineConfig({ port: 1420, strictPort: true, host: host || false, + proxy: { + "/api": { + target: "http://127.0.0.1:8001", + changeOrigin: true, + }, + }, hmr: host ? { protocol: "ws",