feat: 7.2

This commit is contained in:
skytek_xinliang
2026-08-17 11:26:13 +08:00
parent 347eb70d95
commit 332d00db75
8 changed files with 210 additions and 28 deletions
+38 -5
View File
@@ -1,8 +1,41 @@
import type { LoginRequest, LoginResponse } from "./types";
import { api } from "./client";
import type {
AuthResponse,
LoginRequest,
LogoutResponse,
RegisterRequest,
} from "./types";
/** 使用手機門號登入,取得後續查詢課程建議所需的 userID 與 group */
export function login(phone: string): Promise<LoginResponse> {
const body: LoginRequest = { phone };
return api.post("v1/auth/login", { json: body }).json<LoginResponse>();
/** 使用手機門號與密碼登入;登入 Cookie 由共用 HTTP client 自動攜帶。 */
export function login(request: LoginRequest): Promise<AuthResponse>;
export function login(phone: string, password: string): Promise<AuthResponse>;
export function login(
requestOrPhone: LoginRequest | string,
password?: string,
): Promise<AuthResponse> {
if (typeof requestOrPhone === "string") {
if (!password) {
throw new Error("登入需要密碼");
}
return api
.post("v1/auth/login", {
json: { phone: requestOrPhone, password },
})
.json<AuthResponse>();
}
return api
.post("v1/auth/login", { json: requestOrPhone })
.json<AuthResponse>();
}
/** 註冊帳號並建立完整個人資料。 */
export function register(request: RegisterRequest): Promise<AuthResponse> {
return api.post("v1/auth/register", { json: request }).json<AuthResponse>();
}
/** 呼叫後端登出並讓後端清除登入 Cookie。 */
export function logout(): Promise<LogoutResponse> {
return api.post("v1/auth/logout").json<LogoutResponse>();
}
+2
View File
@@ -7,6 +7,8 @@ import ky from "ky";
export const api = ky.create({
// prefix 同時接受相對路徑(/api)與絕對網址,兩種部署情境都涵蓋
prefix: import.meta.env.VITE_API_BASE_URL || "/api",
// 後端以 HttpOnly CookieNetown.Auth)維持登入狀態,前端不讀取 Cookie 值。
credentials: "include",
timeout: 10_000,
retry: { limit: 2 },
});
+3 -5
View File
@@ -40,11 +40,9 @@ function toCourseRow(raw: CourseListApiRow): CourseRow {
};
}
/** 依登入取得的 userID 查詢體適能組別 */
export function fetchGroup(userID: string): Promise<FitnessGroup> {
return api
.get("course/group", { searchParams: { userID } })
.json<FitnessGroup>();
/** 依登入 Cookie 取得目前使用者的體適能組別 */
export function fetchGroup(): Promise<FitnessGroup> {
return api.get("course/group").json<FitnessGroup>();
}
/** 取得是否納入早上時段 */
+12
View File
@@ -0,0 +1,12 @@
import { api } from "./client";
import type { Profile, ProfileRequest } from "./types";
/** 讀取目前登入使用者的個人資料。 */
export function getProfile(): Promise<Profile> {
return api.get("v1/me/profile").json<Profile>();
}
/** 建立或完整更新目前登入使用者的個人資料。 */
export function updateProfile(request: ProfileRequest): Promise<Profile> {
return api.put("v1/me/profile", { json: request }).json<Profile>();
}
@@ -0,0 +1,52 @@
import { api } from "./client";
import type { SelfAssessmentRequest, SelfAssessmentResponse } from "./types";
/**
* 產生一個 UUID v4,供同一份自評草稿的所有重試共用。
* 優先使用瀏覽器的密碼學亂數 API;舊環境才退回 Math.random。
*/
export function createIdempotencyKey(): string {
if (typeof globalThis.crypto?.randomUUID === "function") {
return globalThis.crypto.randomUUID();
}
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (value) => {
const random = Math.floor(Math.random() * 16);
const digit = value === "x" ? random : (random & 0x3) | 0x8;
return digit.toString(16);
});
}
/**
* 讀取目前登入使用者最近一次已保存的自評。
* 訪客自評流程不應呼叫此 API,應先使用 sessionStorage 草稿。
*/
export function getLatestSelfAssessment(): Promise<SelfAssessmentResponse> {
return api
.get("v1/me/self-assessments/latest")
.json<SelfAssessmentResponse>();
}
/**
* 建立一筆正式自評。
*
* ky 預設不重試 POST;此處明確啟用 POST 的網路、逾時與暫時性錯誤重試。
* 每次重試都沿用同一份 headers,因此 Idempotency-Key 不會改變。
* 若呼叫端在 Promise 失敗後再次呼叫,也必須傳入同一個 key。
*/
export function createSelfAssessment(
request: SelfAssessmentRequest,
idempotencyKey = createIdempotencyKey(),
): Promise<SelfAssessmentResponse> {
return api
.post("v1/me/self-assessments", {
json: request,
headers: { "Idempotency-Key": idempotencyKey },
retry: {
limit: 2,
methods: ["post"],
retryOnTimeout: true,
},
})
.json<SelfAssessmentResponse>();
}
+93 -9
View File
@@ -1,11 +1,40 @@
/**
* 課程建議相關的 API 契約。
* 後端尚未開發,這份型別即為前後端約定的介面。
*/
/** 共用個資授權條款版本;不可讓使用者自行輸入或修改。 */
export const CONSENT_VERSION = "v1" as const;
export type ConsentVersion = typeof CONSENT_VERSION;
/** 體適能組別 */
export type FitnessGroup = "low" | "medium" | "high";
/** 性別 */
export type Gender = "male" | "female";
/** 最近 7 天每週大運動頻率 */
export type ExerciseFrequency = "none" | "oneToTwo" | "threeOrMore";
/** 想改善的體適能項目 */
export type WantedImprovement =
| "strength"
| "balance"
| "cardio"
| "flexibility"
| "agility"
| "bodyComposition";
/** 自評疼痛程度 */
export type PainLevel = "none" | "mild" | "moderate" | "severe";
/** 慢性病項目 */
export type ChronicDisease =
| "hypertension"
| "diabetes"
| "hyperlipidemia"
| "heartDisease"
| "osteoarthritis"
| "osteoporosis";
/** 後端計算的正式風險分級 */
export type RiskLevel = "low" | "followUp" | "consultation";
/** 後端以 Y/N 字串表示布林值 */
export type YesNo = "Y" | "N";
@@ -31,15 +60,70 @@ export interface CourseListParams {
night: YesNo;
}
/** 登入請求:僅需手機門號 */
export interface LoginRequest {
phone: string;
/** 個人資料的可寫入欄位;手機與更新時間由後端管理。 */
export interface ProfileRequest {
name: string;
birthday: string;
gender: Gender;
heightCm: number;
weightKg: number;
email: string | null;
emergencyContact: string | null;
emergencyPhone: string | null;
consent: boolean;
consentVersion: ConsentVersion;
}
/** 登入成功回應,userID 供後續課程 API 查詢使用 */
export interface LoginResponse {
/** 個人資料 API 回應。 */
export interface Profile extends ProfileRequest {
phone: string;
updatedAt: string;
}
/** 註冊請求:建立帳號並同時建立完整個人資料。 */
export interface RegisterRequest extends ProfileRequest {
phone: string;
password: string;
}
/** 登入請求。 */
export interface LoginRequest {
phone: string;
password: string;
}
/** 登入/註冊成功回應。Cookie 由瀏覽器管理,前端不保存其值。 */
export interface AuthResponse {
userID: string;
userName: string;
phone: string;
group: FitnessGroup;
profile: Profile | null;
}
/** 舊名稱保留為相容別名,新的 API 請使用 AuthResponse。 */
export type LoginResponse = AuthResponse;
/** 登出成功回應。 */
export interface LogoutResponse {
messageCode: string;
}
/** 建立自評時可送出的欄位;riskLevel 由後端計算。 */
export interface SelfAssessmentRequest {
exerciseFrequency: ExerciseFrequency;
wantedImprovements: WantedImprovement[];
hasFalls: boolean;
painLevel: PainLevel;
chronicDiseases: ChronicDisease[];
}
/** 自評 API 回應,包含後端計算的正式風險分級。 */
export interface SelfAssessmentResponse extends SelfAssessmentRequest {
assessmentId: string;
riskLevel: RiskLevel;
completedAt: string;
}
/** 最近一次自評與建立自評 API 使用相同的回應模型。 */
export type SelfAssessment = SelfAssessmentResponse;
+7 -3
View File
@@ -236,8 +236,8 @@ export const useAppStore = defineStore("app", () => {
return Math.round((done / keys.length) * 100);
});
/** 手機門號登入;後端未完成時走模擬資料,完成後設 VITE_USE_MOCK=false 改打真實 API */
async function login(phone: string) {
/** 手機門號登入;密碼欄位將由登入頁在第 7.3 項補上。 */
async function login(phone: string, password = "") {
if (USE_MOCK) {
isLoggedIn.value = true;
userID.value = "MOCK-USER";
@@ -245,7 +245,11 @@ export const useAppStore = defineStore("app", () => {
return;
}
const response = await apiLogin(phone);
if (!password) {
throw new Error("請輸入登入密碼");
}
const response = await apiLogin({ phone, password });
isLoggedIn.value = true;
userID.value = response.userID;
+3 -6
View File
@@ -15,7 +15,6 @@ import {
fetchNight,
fetchNoon,
} from "@/api/course";
import { useAppStore } from "@/stores/app";
/** 後端尚未完成,預設走前端模擬資料;設定 VITE_USE_MOCK=false 即改打真實 API */
const USE_MOCK = import.meta.env.VITE_USE_MOCK !== "false";
@@ -253,11 +252,9 @@ export const useCourseStore = defineStore("course", () => {
return;
}
// 真實後端:course/group 需要 userIDcourse/morning、noon、night 需要 group
// 兩者有相依順序,因此無法四支平行呼叫(與 doc/課程建議API規格.md 的示意圖不同,
// 以 doc/課程建議_API_前端使用說明_20260806.md 的建議測試流程為準)
const { userID } = useAppStore();
const groupValue = await fetchGroup(userID);
// 真實後端:course/group 由登入 Cookie 識別使用者;
// course/morning、noon、night 需要 group,兩者有相依順序。
const groupValue = await fetchGroup();
const [morningValue, noonValue, nightValue] = await Promise.all([
fetchMorning(groupValue),