feat: 7.2
This commit is contained in:
@@ -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>();
|
||||
}
|
||||
|
||||
@@ -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 Cookie(Netown.Auth)維持登入狀態,前端不讀取 Cookie 值。
|
||||
credentials: "include",
|
||||
timeout: 10_000,
|
||||
retry: { limit: 2 },
|
||||
});
|
||||
|
||||
@@ -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>();
|
||||
}
|
||||
|
||||
/** 取得是否納入早上時段 */
|
||||
|
||||
@@ -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>();
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 需要 userID,course/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),
|
||||
|
||||
Reference in New Issue
Block a user