feat: 7.2
This commit is contained in:
@@ -1,8 +1,41 @@
|
|||||||
import type { LoginRequest, LoginResponse } from "./types";
|
|
||||||
import { api } from "./client";
|
import { api } from "./client";
|
||||||
|
import type {
|
||||||
|
AuthResponse,
|
||||||
|
LoginRequest,
|
||||||
|
LogoutResponse,
|
||||||
|
RegisterRequest,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
/** 使用手機門號登入,取得後續查詢課程建議所需的 userID 與 group */
|
/** 使用手機門號與密碼登入;登入 Cookie 由共用 HTTP client 自動攜帶。 */
|
||||||
export function login(phone: string): Promise<LoginResponse> {
|
export function login(request: LoginRequest): Promise<AuthResponse>;
|
||||||
const body: LoginRequest = { phone };
|
export function login(phone: string, password: string): Promise<AuthResponse>;
|
||||||
return api.post("v1/auth/login", { json: body }).json<LoginResponse>();
|
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({
|
export const api = ky.create({
|
||||||
// prefix 同時接受相對路徑(/api)與絕對網址,兩種部署情境都涵蓋
|
// prefix 同時接受相對路徑(/api)與絕對網址,兩種部署情境都涵蓋
|
||||||
prefix: import.meta.env.VITE_API_BASE_URL || "/api",
|
prefix: import.meta.env.VITE_API_BASE_URL || "/api",
|
||||||
|
// 後端以 HttpOnly Cookie(Netown.Auth)維持登入狀態,前端不讀取 Cookie 值。
|
||||||
|
credentials: "include",
|
||||||
timeout: 10_000,
|
timeout: 10_000,
|
||||||
retry: { limit: 2 },
|
retry: { limit: 2 },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -40,11 +40,9 @@ function toCourseRow(raw: CourseListApiRow): CourseRow {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 依登入取得的 userID 查詢體適能組別 */
|
/** 依登入 Cookie 取得目前使用者的體適能組別。 */
|
||||||
export function fetchGroup(userID: string): Promise<FitnessGroup> {
|
export function fetchGroup(): Promise<FitnessGroup> {
|
||||||
return api
|
return api.get("course/group").json<FitnessGroup>();
|
||||||
.get("course/group", { searchParams: { userID } })
|
|
||||||
.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 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 字串表示布林值 */
|
/** 後端以 Y/N 字串表示布林值 */
|
||||||
export type YesNo = "Y" | "N";
|
export type YesNo = "Y" | "N";
|
||||||
|
|
||||||
@@ -31,15 +60,70 @@ export interface CourseListParams {
|
|||||||
night: YesNo;
|
night: YesNo;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 登入請求:僅需手機門號 */
|
/** 個人資料的可寫入欄位;手機與更新時間由後端管理。 */
|
||||||
export interface LoginRequest {
|
export interface ProfileRequest {
|
||||||
phone: string;
|
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 查詢使用 */
|
/** 個人資料 API 回應。 */
|
||||||
export interface LoginResponse {
|
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;
|
userID: string;
|
||||||
userName: string;
|
userName: string;
|
||||||
phone: string;
|
phone: string;
|
||||||
group: FitnessGroup;
|
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);
|
return Math.round((done / keys.length) * 100);
|
||||||
});
|
});
|
||||||
|
|
||||||
/** 手機門號登入;後端未完成時走模擬資料,完成後設 VITE_USE_MOCK=false 改打真實 API */
|
/** 手機門號登入;密碼欄位將由登入頁在第 7.3 項補上。 */
|
||||||
async function login(phone: string) {
|
async function login(phone: string, password = "") {
|
||||||
if (USE_MOCK) {
|
if (USE_MOCK) {
|
||||||
isLoggedIn.value = true;
|
isLoggedIn.value = true;
|
||||||
userID.value = "MOCK-USER";
|
userID.value = "MOCK-USER";
|
||||||
@@ -245,7 +245,11 @@ export const useAppStore = defineStore("app", () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await apiLogin(phone);
|
if (!password) {
|
||||||
|
throw new Error("請輸入登入密碼");
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await apiLogin({ phone, password });
|
||||||
|
|
||||||
isLoggedIn.value = true;
|
isLoggedIn.value = true;
|
||||||
userID.value = response.userID;
|
userID.value = response.userID;
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import {
|
|||||||
fetchNight,
|
fetchNight,
|
||||||
fetchNoon,
|
fetchNoon,
|
||||||
} from "@/api/course";
|
} from "@/api/course";
|
||||||
import { useAppStore } from "@/stores/app";
|
|
||||||
|
|
||||||
/** 後端尚未完成,預設走前端模擬資料;設定 VITE_USE_MOCK=false 即改打真實 API */
|
/** 後端尚未完成,預設走前端模擬資料;設定 VITE_USE_MOCK=false 即改打真實 API */
|
||||||
const USE_MOCK = import.meta.env.VITE_USE_MOCK !== "false";
|
const USE_MOCK = import.meta.env.VITE_USE_MOCK !== "false";
|
||||||
@@ -253,11 +252,9 @@ export const useCourseStore = defineStore("course", () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 真實後端:course/group 需要 userID,course/morning、noon、night 需要 group,
|
// 真實後端:course/group 由登入 Cookie 識別使用者;
|
||||||
// 兩者有相依順序,因此無法四支平行呼叫(與 doc/課程建議API規格.md 的示意圖不同,
|
// course/morning、noon、night 需要 group,兩者有相依順序。
|
||||||
// 以 doc/課程建議_API_前端使用說明_20260806.md 的建議測試流程為準)
|
const groupValue = await fetchGroup();
|
||||||
const { userID } = useAppStore();
|
|
||||||
const groupValue = await fetchGroup(userID);
|
|
||||||
|
|
||||||
const [morningValue, noonValue, nightValue] = await Promise.all([
|
const [morningValue, noonValue, nightValue] = await Promise.all([
|
||||||
fetchMorning(groupValue),
|
fetchMorning(groupValue),
|
||||||
|
|||||||
Reference in New Issue
Block a user