feat: 後端API
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
import type { LoginRequest, LoginResponse } from './types'
|
||||
import { api } from './client'
|
||||
|
||||
/** 使用手機門號登入,取得後續查詢課程建議所需的 userID 與 group */
|
||||
export function login (phone: string): Promise<LoginResponse> {
|
||||
const body: LoginRequest = { phone }
|
||||
return api.post('v1/auth/login', { json: body }).json<LoginResponse>()
|
||||
}
|
||||
@@ -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<FitnessGroup> {
|
||||
return api.get('course/group').json<FitnessGroup>()
|
||||
/**
|
||||
* 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<FitnessGroup> {
|
||||
return api.get('course/group', { searchParams: { userID } }).json<FitnessGroup>()
|
||||
}
|
||||
|
||||
/** 取得是否納入早上時段 */
|
||||
export function fetchMorning (): Promise<YesNo> {
|
||||
return api.get('course/morning').json<YesNo>()
|
||||
export function fetchMorning (group: FitnessGroup): Promise<YesNo> {
|
||||
return api.get('course/morning', { searchParams: { group } }).json<YesNo>()
|
||||
}
|
||||
|
||||
/** 取得是否納入中午時段 */
|
||||
export function fetchNoon (): Promise<YesNo> {
|
||||
return api.get('course/noon').json<YesNo>()
|
||||
export function fetchNoon (group: FitnessGroup): Promise<YesNo> {
|
||||
return api.get('course/noon', { searchParams: { group } }).json<YesNo>()
|
||||
}
|
||||
|
||||
/** 取得是否納入晚上時段 */
|
||||
export function fetchNight (): Promise<YesNo> {
|
||||
return api.get('course/night').json<YesNo>()
|
||||
export function fetchNight (group: FitnessGroup): Promise<YesNo> {
|
||||
return api.get('course/night', { searchParams: { group } }).json<YesNo>()
|
||||
}
|
||||
|
||||
/** 以組別與時段條件取得一週課表 */
|
||||
export function fetchCourseList (params: CourseListParams): Promise<CourseList> {
|
||||
return api.get('course/list', { searchParams: { ...params } }).json<CourseList>()
|
||||
export async function fetchCourseList (params: CourseListParams): Promise<CourseList> {
|
||||
const rows = await api
|
||||
.get('course/list', { searchParams: { ...params } })
|
||||
.json<CourseListApiRow[]>()
|
||||
|
||||
return rows.map(row => toCourseRow(row))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -17,29 +17,26 @@
|
||||
<v-card-text> 請輸入您的手機號碼或信箱 </v-card-text>
|
||||
<!-- 表單 -->
|
||||
<v-form class="pa-4" @submit.prevent="handleLogin">
|
||||
<v-text-field
|
||||
v-model="account"
|
||||
<v-alert
|
||||
v-if="errorMessage"
|
||||
class="mb-4"
|
||||
color="primary"
|
||||
hide-details="auto"
|
||||
label="手機號碼 / 信箱"
|
||||
placeholder="例如: 0912345678"
|
||||
prepend-inner-icon="mdi-cellphone-android"
|
||||
required
|
||||
rounded="lg"
|
||||
variant="outlined"
|
||||
/>
|
||||
density="compact"
|
||||
type="error"
|
||||
variant="tonal"
|
||||
>
|
||||
{{ errorMessage }}
|
||||
</v-alert>
|
||||
|
||||
<v-text-field
|
||||
v-model="password"
|
||||
v-model="account"
|
||||
class="mb-6"
|
||||
color="primary"
|
||||
hide-details="auto"
|
||||
label="密碼"
|
||||
placeholder="請輸入密碼"
|
||||
prepend-inner-icon="mdi-lock-outline"
|
||||
label="手機號碼"
|
||||
placeholder="例如: 0910666888"
|
||||
prepend-inner-icon="mdi-cellphone-android"
|
||||
required
|
||||
rounded="lg"
|
||||
type="password"
|
||||
variant="outlined"
|
||||
/>
|
||||
|
||||
@@ -106,6 +103,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { HTTPError } from 'ky'
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import BrandAvatar from '@/components/BrandAvatar.vue'
|
||||
@@ -114,28 +112,75 @@ import { useAppStore } from '@/stores/app'
|
||||
const store = useAppStore()
|
||||
const router = useRouter()
|
||||
|
||||
const account = ref('0912-345-678')
|
||||
const password = ref('')
|
||||
const account = ref('')
|
||||
const loading = ref(false)
|
||||
const lineLoading = ref(false)
|
||||
const errorMessage = ref('')
|
||||
|
||||
function handleLogin () {
|
||||
if (!account.value) return
|
||||
loading.value = true
|
||||
setTimeout(() => {
|
||||
store.login(account.value)
|
||||
loading.value = false
|
||||
router.push('/')
|
||||
}, 1000)
|
||||
/** 後端要求純數字、09 開頭、共 10 碼;允許使用者輸入時夾雜連字號或空白 */
|
||||
function normalizePhone (value: string) {
|
||||
return value.replace(/\D/g, '')
|
||||
}
|
||||
|
||||
function _handleLineLogin () {
|
||||
lineLoading.value = true
|
||||
setTimeout(() => {
|
||||
store.login('0912-345-678') // LINE 模擬登入預設手機
|
||||
lineLoading.value = false
|
||||
interface ErrorResponseBody {
|
||||
/** 一般錯誤格式,例如 401 手機門號不存在 */
|
||||
message?: string
|
||||
/** 驗證失敗時(400)後端走 ASP.NET Core 的 ProblemDetails 格式 */
|
||||
errors?: Record<string, string[]>
|
||||
}
|
||||
|
||||
/** 優先取用後端回應 body 的錯誤訊息,取不到時退回通用錯誤訊息 */
|
||||
function resolveErrorMessage (error: unknown): string {
|
||||
// ky 建立 HTTPError 時已經把 body 讀進 error.data,body 已被消耗,
|
||||
// 不能再呼叫 error.response.json()(會丟 "Body has already been read")
|
||||
if (error instanceof HTTPError && error.data && typeof error.data === 'object') {
|
||||
const body = error.data as ErrorResponseBody
|
||||
|
||||
if (body.message) {
|
||||
return body.message
|
||||
}
|
||||
|
||||
const firstFieldError = Object.values(body.errors ?? {})[0]?.[0]
|
||||
if (firstFieldError) {
|
||||
return firstFieldError
|
||||
}
|
||||
}
|
||||
|
||||
return error instanceof Error ? error.message : '登入失敗,請稍後再試。'
|
||||
}
|
||||
|
||||
async function handleLogin () {
|
||||
const phone = normalizePhone(account.value)
|
||||
|
||||
if (!/^09\d{8}$/.test(phone)) {
|
||||
errorMessage.value = '請輸入正確的手機門號格式(09 開頭,共 10 碼)'
|
||||
return
|
||||
}
|
||||
|
||||
errorMessage.value = ''
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
await store.login(phone)
|
||||
router.push('/')
|
||||
}, 1200)
|
||||
} catch (error) {
|
||||
errorMessage.value = resolveErrorMessage(error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function _handleLineLogin () {
|
||||
lineLoading.value = true
|
||||
|
||||
try {
|
||||
await store.login('0910666888') // LINE 模擬登入預設手機
|
||||
router.push('/')
|
||||
} catch (error) {
|
||||
errorMessage.value = resolveErrorMessage(error)
|
||||
} finally {
|
||||
lineLoading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -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<UserProfile>(createDefaultUserProfile())
|
||||
const selfAssessment = ref<SelfAssessment>(createDefaultSelfAssessment())
|
||||
const appointments = ref<Appointment[]>(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<UserProfile>) {
|
||||
@@ -374,6 +393,7 @@ export const useAppStore = defineStore('app', () => {
|
||||
|
||||
return {
|
||||
isLoggedIn,
|
||||
userID,
|
||||
userProfile,
|
||||
selfAssessment,
|
||||
appointments,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user