From 60c4a414a3bddf75a54e571c561a63a5445ab10f Mon Sep 17 00:00:00 2001 From: skytek_xinliang Date: Thu, 6 Aug 2026 14:34:20 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=BE=8C=E7=AB=AFAPI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/citizen-frontend/.env.example | 16 +++- app/citizen-frontend/src/api/auth.ts | 8 ++ app/citizen-frontend/src/api/course.ts | 56 ++++++++--- app/citizen-frontend/src/api/types.ts | 13 +++ app/citizen-frontend/src/pages/login.vue | 109 +++++++++++++++------- app/citizen-frontend/src/stores/app.ts | 24 ++++- app/citizen-frontend/src/stores/course.ts | 16 +++- app/citizen-frontend/vite.config.mts | 62 +++++++----- 8 files changed, 230 insertions(+), 74 deletions(-) create mode 100644 app/citizen-frontend/src/api/auth.ts diff --git a/app/citizen-frontend/.env.example b/app/citizen-frontend/.env.example index 3f0bad7..5b3652d 100644 --- a/app/citizen-frontend/.env.example +++ b/app/citizen-frontend/.env.example @@ -1,5 +1,19 @@ -# 後端 API base URL;留空則使用同網域的 /api +# 後端 API base URL;留空則使用同網域的相對路徑 /api +# +# 目前的後端沒有開放 CORS,瀏覽器直接跨網域呼叫會被擋下(OPTIONS preflight 回 405、 +# 回應也沒有 Access-Control-Allow-Origin)。因此本機開發改用下面的 API_PROXY_TARGET +# 讓 dev server 轉發 /api,這裡的 VITE_API_BASE_URL 留空即可,不要直接填後端網址。 +# +# 本機開發:複製這份檔案為 .env.local(已在根目錄 .gitignore 以 *.local 排除,不進版控), +# 填入內網後端位址,例如: +# API_PROXY_TARGET=http://192.168.89.54:9004/service/api +# VITE_API_BASE_URL= +# +# 正式站(Cloudflare Pages):後端目前是內網位址,Cloudflare 的邊緣節點連不到, +# 尚未有可用的正式站串接方式(例如另開對外網址、或架 Cloudflare Tunnel), +# 待確定後再設定 Cloudflare Pages 專案的 Settings → Environment variables。 VITE_API_BASE_URL= # 後端尚未完成前保持 true,改為 false 才會實際發出 API 請求 +# 本機串接測試請在 .env.local 覆寫為 false VITE_USE_MOCK=true diff --git a/app/citizen-frontend/src/api/auth.ts b/app/citizen-frontend/src/api/auth.ts new file mode 100644 index 0000000..4af5100 --- /dev/null +++ b/app/citizen-frontend/src/api/auth.ts @@ -0,0 +1,8 @@ +import type { LoginRequest, LoginResponse } from './types' +import { api } from './client' + +/** 使用手機門號登入,取得後續查詢課程建議所需的 userID 與 group */ +export function login (phone: string): Promise { + const body: LoginRequest = { phone } + return api.post('v1/auth/login', { json: body }).json() +} diff --git a/app/citizen-frontend/src/api/course.ts b/app/citizen-frontend/src/api/course.ts index 1159ee5..52d7ac5 100644 --- a/app/citizen-frontend/src/api/course.ts +++ b/app/citizen-frontend/src/api/course.ts @@ -1,4 +1,4 @@ -import type { CourseList, CourseListParams, FitnessGroup, YesNo } from './types' +import type { CourseList, CourseListParams, CourseRow, FitnessGroup, YesNo } from './types' import { api } from './client' /** @@ -6,27 +6,59 @@ import { api } from './client' * 端點路徑集中在此檔,後端定案後只需改這裡。 */ -/** 取得使用者的體適能組別 */ -export function fetchGroup (): Promise { - return api.get('course/group').json() +/** + * course/list 實際回傳格式:weekdayCode("0"=週日…"6"=週六)與 weekdayText, + * 與 doc/課程建議API規格.md 記載的 `{ day, morning, noon, night }` 不同,故在此轉換。 + */ +interface CourseListApiRow { + weekdayCode: string + weekdayText: string + morning: string + noon: string + night: string +} + +/** weekdayCode 轉換為前端沿用的 1(週一)…7(週日)編號 */ +function toDay (weekdayCode: string): number { + const code = Number(weekdayCode) + return code === 0 ? 7 : code +} + +/** 未勾選的時段後端回傳空字串,轉為 undefined 以符合 CourseRow 的選填語意 */ +function toCourseRow (raw: CourseListApiRow): CourseRow { + return { + day: toDay(raw.weekdayCode), + morning: raw.morning || undefined, + noon: raw.noon || undefined, + night: raw.night || undefined, + } +} + +/** 依登入取得的 userID 查詢體適能組別 */ +export function fetchGroup (userID: string): Promise { + return api.get('course/group', { searchParams: { userID } }).json() } /** 取得是否納入早上時段 */ -export function fetchMorning (): Promise { - return api.get('course/morning').json() +export function fetchMorning (group: FitnessGroup): Promise { + return api.get('course/morning', { searchParams: { group } }).json() } /** 取得是否納入中午時段 */ -export function fetchNoon (): Promise { - return api.get('course/noon').json() +export function fetchNoon (group: FitnessGroup): Promise { + return api.get('course/noon', { searchParams: { group } }).json() } /** 取得是否納入晚上時段 */ -export function fetchNight (): Promise { - return api.get('course/night').json() +export function fetchNight (group: FitnessGroup): Promise { + return api.get('course/night', { searchParams: { group } }).json() } /** 以組別與時段條件取得一週課表 */ -export function fetchCourseList (params: CourseListParams): Promise { - return api.get('course/list', { searchParams: { ...params } }).json() +export async function fetchCourseList (params: CourseListParams): Promise { + const rows = await api + .get('course/list', { searchParams: { ...params } }) + .json() + + return rows.map(row => toCourseRow(row)) } diff --git a/app/citizen-frontend/src/api/types.ts b/app/citizen-frontend/src/api/types.ts index b9dc8e8..50c03d6 100644 --- a/app/citizen-frontend/src/api/types.ts +++ b/app/citizen-frontend/src/api/types.ts @@ -30,3 +30,16 @@ export interface CourseListParams { noon: YesNo night: YesNo } + +/** 登入請求:僅需手機門號 */ +export interface LoginRequest { + phone: string +} + +/** 登入成功回應,userID 供後續課程 API 查詢使用 */ +export interface LoginResponse { + userID: string + userName: string + phone: string + group: FitnessGroup +} diff --git a/app/citizen-frontend/src/pages/login.vue b/app/citizen-frontend/src/pages/login.vue index 370a2b5..24cf1c3 100644 --- a/app/citizen-frontend/src/pages/login.vue +++ b/app/citizen-frontend/src/pages/login.vue @@ -17,29 +17,26 @@ 請輸入您的手機號碼或信箱 - + density="compact" + type="error" + variant="tonal" + > + {{ errorMessage }} + @@ -106,6 +103,7 @@ diff --git a/app/citizen-frontend/src/stores/app.ts b/app/citizen-frontend/src/stores/app.ts index 5efdbd0..2ffddd5 100644 --- a/app/citizen-frontend/src/stores/app.ts +++ b/app/citizen-frontend/src/stores/app.ts @@ -1,5 +1,9 @@ import { defineStore } from 'pinia' import { computed, ref } from 'vue' +import { login as apiLogin } from '@/api/auth' + +/** 後端尚未完成前保持 true,改為 false 才會實際呼叫登入 API */ +const USE_MOCK = import.meta.env.VITE_USE_MOCK !== 'false' export interface UserProfile { name: string @@ -198,6 +202,8 @@ function createDefaultRadarData () { export const useAppStore = defineStore('app', () => { const isLoggedIn = ref(false) + /** 登入取得的使用者識別碼,課程建議等 API 需要此值查詢 */ + const userID = ref('') const userProfile = ref(createDefaultUserProfile()) const selfAssessment = ref(createDefaultSelfAssessment()) const appointments = ref(createDefaultAppointments()) @@ -224,13 +230,26 @@ export const useAppStore = defineStore('app', () => { return Math.round((done / keys.length) * 100) }) - function login (phone: string) { + /** 手機門號登入;後端未完成時走模擬資料,完成後設 VITE_USE_MOCK=false 改打真實 API */ + async function login (phone: string) { + if (USE_MOCK) { + isLoggedIn.value = true + userID.value = 'MOCK-USER' + userProfile.value.phone = phone + return + } + + const response = await apiLogin(phone) + isLoggedIn.value = true - userProfile.value.phone = phone + userID.value = response.userID + userProfile.value.phone = response.phone + userProfile.value.name = response.userName } function logout () { isLoggedIn.value = false + userID.value = '' } function updateProfile (profile: Partial) { @@ -374,6 +393,7 @@ export const useAppStore = defineStore('app', () => { return { isLoggedIn, + userID, userProfile, selfAssessment, appointments, diff --git a/app/citizen-frontend/src/stores/course.ts b/app/citizen-frontend/src/stores/course.ts index 03c67dc..8e16a34 100644 --- a/app/citizen-frontend/src/stores/course.ts +++ b/app/citizen-frontend/src/stores/course.ts @@ -15,6 +15,7 @@ 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' @@ -152,11 +153,16 @@ export const useCourseStore = defineStore('course', () => { return } - const [groupValue, morningValue, noonValue, nightValue] = await Promise.all([ - fetchGroup(), - fetchMorning(), - fetchNoon(), - fetchNight(), + // 真實後端:course/group 需要 userID,course/morning、noon、night 需要 group, + // 兩者有相依順序,因此無法四支平行呼叫(與 doc/課程建議API規格.md 的示意圖不同, + // 以 doc/課程建議_API_前端使用說明_20260806.md 的建議測試流程為準) + const { userID } = useAppStore() + const groupValue = await fetchGroup(userID) + + const [morningValue, noonValue, nightValue] = await Promise.all([ + fetchMorning(groupValue), + fetchNoon(groupValue), + fetchNight(groupValue), ]) group.value = groupValue diff --git a/app/citizen-frontend/vite.config.mts b/app/citizen-frontend/vite.config.mts index 47c09e2..1d49a0b 100644 --- a/app/citizen-frontend/vite.config.mts +++ b/app/citizen-frontend/vite.config.mts @@ -1,30 +1,48 @@ import { fileURLToPath, URL } from 'node:url' import Vue from '@vitejs/plugin-vue' -import { defineConfig } from 'vite' +import { defineConfig, loadEnv } from 'vite' import Vuetify, { transformAssetUrls } from 'vite-plugin-vuetify' // https://vitejs.dev/config/ -export default defineConfig({ - plugins: [ - Vue({ - template: { transformAssetUrls }, - }), - // https://github.com/vuetifyjs/vuetify-loader/tree/master/packages/vite-plugin#readme - Vuetify({ - autoImport: true, - styles: { - configFile: 'src/styles/settings.scss', +export default defineConfig(({ mode }) => { + // 只在建置設定內讀取(不進 client bundle):後端目前沒有開放 CORS, + // 本機開發改用 dev server 轉發 /api,避開瀏覽器的跨網域限制。 + // 在 .env.local 設定 API_PROXY_TARGET(例如內網後端位址)即可啟用; + // 未設定時維持原行為(前端走 VITE_USE_MOCK 的模擬資料)。 + const env = loadEnv(mode, process.cwd(), '') + const apiProxyTarget = env.API_PROXY_TARGET + + return { + plugins: [ + Vue({ + template: { transformAssetUrls }, + }), + // https://github.com/vuetifyjs/vuetify-loader/tree/master/packages/vite-plugin#readme + Vuetify({ + autoImport: true, + styles: { + configFile: 'src/styles/settings.scss', + }, + }), + ], + define: { 'process.env': {} }, + resolve: { + alias: { + '@': fileURLToPath(new URL('src', import.meta.url)), }, - }), - ], - define: { 'process.env': {} }, - resolve: { - alias: { - '@': fileURLToPath(new URL('src', import.meta.url)), + extensions: ['.js', '.json', '.jsx', '.mjs', '.ts', '.tsx', '.vue'], }, - extensions: ['.js', '.json', '.jsx', '.mjs', '.ts', '.tsx', '.vue'], - }, - server: { - port: 3678, - }, + server: { + port: 3678, + proxy: apiProxyTarget + ? { + '/api': { + target: apiProxyTarget, + changeOrigin: true, + rewrite: (path: string) => path.replace(/^\/api/, ''), + }, + } + : undefined, + }, + } })