This commit is contained in:
fengchuanhn@gmail.com
2026-05-15 18:50:42 +08:00
parent 584d4c47a1
commit 28b2b4d686
9 changed files with 218 additions and 53 deletions

View File

@@ -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<T>`
所有 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 <access_token>`
- 登出:`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`。
## 通用原则
- 用户界面文案默认使用简体中文

3
.env.example Normal file
View File

@@ -0,0 +1,3 @@
# 开发默认走 Vite 代理 /api -> http://127.0.0.1:8001
# 生产或直连后端时可设为完整地址,例如:
# VITE_API_BASE_URL=http://127.0.0.1:8001/api/v1

38
src/api/auth.js Normal file
View File

@@ -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}`,
},
});
}

62
src/api/client.js Normal file
View File

@@ -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 };

View File

@@ -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 登录尚未实现" };
},
},
});

View File

@@ -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" });
}
</script>

View File

@@ -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,
});

View File

@@ -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,

View File

@@ -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",