11
This commit is contained in:
@@ -32,6 +32,62 @@ src-tauri/ # Tauri Rust 与配置
|
|||||||
- `npm run tauri dev` — 桌面开发
|
- `npm run tauri dev` — 桌面开发
|
||||||
- `npm run build` / `npm run tauri build` — 构建(前端无 `tsc` 步骤)
|
- `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
3
.env.example
Normal 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
38
src/api/auth.js
Normal 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
62
src/api/client.js
Normal 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 };
|
||||||
@@ -1,24 +1,25 @@
|
|||||||
import { defineStore } from "pinia";
|
import { defineStore } from "pinia";
|
||||||
|
import { loginApi, logoutApi, registerApi } from "../api/auth.js";
|
||||||
|
|
||||||
const USERS_KEY = "aiclient_users";
|
|
||||||
const TOKEN_KEY = "aiclient_token";
|
const TOKEN_KEY = "aiclient_token";
|
||||||
const USER_KEY = "aiclient_current_user";
|
const USER_KEY = "aiclient_current_user";
|
||||||
|
|
||||||
function loadUsers() {
|
function applyTokenResponse(store, res, fallbackMessage) {
|
||||||
try {
|
if (!res.ok) {
|
||||||
const raw = localStorage.getItem(USERS_KEY);
|
return { ok: false, message: res.message || fallbackMessage };
|
||||||
return raw ? JSON.parse(raw) : [];
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
function saveUsers(users) {
|
const data = res.data;
|
||||||
localStorage.setItem(USERS_KEY, JSON.stringify(users));
|
if (!data?.access_token || !data?.user) {
|
||||||
}
|
return { ok: false, message: "服务器返回数据异常" };
|
||||||
|
}
|
||||||
|
|
||||||
function createToken(username) {
|
store.setSession(data.access_token, {
|
||||||
return btoa(`${username}:${Date.now()}`);
|
id: data.user.id,
|
||||||
|
username: data.user.username,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { ok: true, message: res.message || fallbackMessage };
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useAuthStore = defineStore("auth", {
|
export const useAuthStore = defineStore("auth", {
|
||||||
@@ -39,7 +40,21 @@ export const useAuthStore = defineStore("auth", {
|
|||||||
},
|
},
|
||||||
|
|
||||||
actions: {
|
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();
|
const name = username?.trim();
|
||||||
if (!name || !password) {
|
if (!name || !password) {
|
||||||
return { ok: false, message: "用户名和密码不能为空" };
|
return { ok: false, message: "用户名和密码不能为空" };
|
||||||
@@ -51,51 +66,36 @@ export const useAuthStore = defineStore("auth", {
|
|||||||
return { ok: false, message: "密码长度至少为 6 位" };
|
return { ok: false, message: "密码长度至少为 6 位" };
|
||||||
}
|
}
|
||||||
|
|
||||||
const users = loadUsers();
|
const res = await registerApi({
|
||||||
if (users.some((u) => u.username === name)) {
|
username: name,
|
||||||
return { ok: false, message: "用户名已存在" };
|
password,
|
||||||
}
|
confirmPassword,
|
||||||
|
});
|
||||||
|
|
||||||
users.push({ username: name, password });
|
return applyTokenResponse(this, res, "注册成功");
|
||||||
saveUsers(users);
|
|
||||||
|
|
||||||
return this.login({ username: name, password });
|
|
||||||
},
|
},
|
||||||
|
|
||||||
login({ username, password }) {
|
async login({ username, password }) {
|
||||||
const name = username?.trim();
|
const name = username?.trim();
|
||||||
if (!name || !password) {
|
if (!name || !password) {
|
||||||
return { ok: false, message: "用户名和密码不能为空" };
|
return { ok: false, message: "用户名和密码不能为空" };
|
||||||
}
|
}
|
||||||
|
|
||||||
const users = loadUsers();
|
const res = await loginApi({
|
||||||
const user = users.find((u) => u.username === name);
|
username: name,
|
||||||
if (!user || user.password !== password) {
|
password,
|
||||||
return { ok: false, message: "用户名或密码错误" };
|
});
|
||||||
|
|
||||||
|
return applyTokenResponse(this, res, "登录成功");
|
||||||
|
},
|
||||||
|
|
||||||
|
async logout() {
|
||||||
|
const token = this.token;
|
||||||
|
if (token) {
|
||||||
|
await logoutApi(token);
|
||||||
}
|
}
|
||||||
|
this.clearSession();
|
||||||
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);
|
|
||||||
return { ok: true, message: "已退出登录" };
|
return { ok: true, message: "已退出登录" };
|
||||||
},
|
},
|
||||||
|
|
||||||
// 预留:后续对接 Tauri invoke / HTTP API
|
|
||||||
async loginWithApi(_credentials) {
|
|
||||||
return { ok: false, message: "API 登录尚未实现" };
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import { useAuthStore } from "../stores/auth";
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
|
|
||||||
function onLogout() {
|
async function onLogout() {
|
||||||
auth.logout();
|
await auth.logout();
|
||||||
router.push({ name: "login" });
|
router.push({ name: "login" });
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ async function onSubmit() {
|
|||||||
feedback.value = { severity: "", message: "" };
|
feedback.value = { severity: "", message: "" };
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
|
|
||||||
const result = auth.login({
|
const result = await auth.login({
|
||||||
username: username.value,
|
username: username.value,
|
||||||
password: password.value,
|
password: password.value,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ async function onSubmit() {
|
|||||||
feedback.value = { severity: "", message: "" };
|
feedback.value = { severity: "", message: "" };
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
|
|
||||||
const result = auth.register({
|
const result = await auth.register({
|
||||||
username: username.value,
|
username: username.value,
|
||||||
password: password.value,
|
password: password.value,
|
||||||
confirmPassword: confirmPassword.value,
|
confirmPassword: confirmPassword.value,
|
||||||
|
|||||||
@@ -11,6 +11,12 @@ export default defineConfig({
|
|||||||
port: 1420,
|
port: 1420,
|
||||||
strictPort: true,
|
strictPort: true,
|
||||||
host: host || false,
|
host: host || false,
|
||||||
|
proxy: {
|
||||||
|
"/api": {
|
||||||
|
target: "http://127.0.0.1:8001",
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
hmr: host
|
hmr: host
|
||||||
? {
|
? {
|
||||||
protocol: "ws",
|
protocol: "ws",
|
||||||
|
|||||||
Reference in New Issue
Block a user