feat: 後端API
This commit is contained in:
@@ -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=
|
VITE_API_BASE_URL=
|
||||||
|
|
||||||
# 後端尚未完成前保持 true,改為 false 才會實際發出 API 請求
|
# 後端尚未完成前保持 true,改為 false 才會實際發出 API 請求
|
||||||
|
# 本機串接測試請在 .env.local 覆寫為 false
|
||||||
VITE_USE_MOCK=true
|
VITE_USE_MOCK=true
|
||||||
|
|||||||
@@ -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'
|
import { api } from './client'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -6,27 +6,59 @@ import { api } from './client'
|
|||||||
* 端點路徑集中在此檔,後端定案後只需改這裡。
|
* 端點路徑集中在此檔,後端定案後只需改這裡。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** 取得使用者的體適能組別 */
|
/**
|
||||||
export function fetchGroup (): Promise<FitnessGroup> {
|
* course/list 實際回傳格式:weekdayCode("0"=週日…"6"=週六)與 weekdayText,
|
||||||
return api.get('course/group').json<FitnessGroup>()
|
* 與 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> {
|
export function fetchMorning (group: FitnessGroup): Promise<YesNo> {
|
||||||
return api.get('course/morning').json<YesNo>()
|
return api.get('course/morning', { searchParams: { group } }).json<YesNo>()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 取得是否納入中午時段 */
|
/** 取得是否納入中午時段 */
|
||||||
export function fetchNoon (): Promise<YesNo> {
|
export function fetchNoon (group: FitnessGroup): Promise<YesNo> {
|
||||||
return api.get('course/noon').json<YesNo>()
|
return api.get('course/noon', { searchParams: { group } }).json<YesNo>()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 取得是否納入晚上時段 */
|
/** 取得是否納入晚上時段 */
|
||||||
export function fetchNight (): Promise<YesNo> {
|
export function fetchNight (group: FitnessGroup): Promise<YesNo> {
|
||||||
return api.get('course/night').json<YesNo>()
|
return api.get('course/night', { searchParams: { group } }).json<YesNo>()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 以組別與時段條件取得一週課表 */
|
/** 以組別與時段條件取得一週課表 */
|
||||||
export function fetchCourseList (params: CourseListParams): Promise<CourseList> {
|
export async function fetchCourseList (params: CourseListParams): Promise<CourseList> {
|
||||||
return api.get('course/list', { searchParams: { ...params } }).json<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
|
noon: YesNo
|
||||||
night: 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-card-text> 請輸入您的手機號碼或信箱 </v-card-text>
|
||||||
<!-- 表單 -->
|
<!-- 表單 -->
|
||||||
<v-form class="pa-4" @submit.prevent="handleLogin">
|
<v-form class="pa-4" @submit.prevent="handleLogin">
|
||||||
<v-text-field
|
<v-alert
|
||||||
v-model="account"
|
v-if="errorMessage"
|
||||||
class="mb-4"
|
class="mb-4"
|
||||||
color="primary"
|
density="compact"
|
||||||
hide-details="auto"
|
type="error"
|
||||||
label="手機號碼 / 信箱"
|
variant="tonal"
|
||||||
placeholder="例如: 0912345678"
|
>
|
||||||
prepend-inner-icon="mdi-cellphone-android"
|
{{ errorMessage }}
|
||||||
required
|
</v-alert>
|
||||||
rounded="lg"
|
|
||||||
variant="outlined"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<v-text-field
|
<v-text-field
|
||||||
v-model="password"
|
v-model="account"
|
||||||
class="mb-6"
|
class="mb-6"
|
||||||
color="primary"
|
color="primary"
|
||||||
hide-details="auto"
|
hide-details="auto"
|
||||||
label="密碼"
|
label="手機號碼"
|
||||||
placeholder="請輸入密碼"
|
placeholder="例如: 0910666888"
|
||||||
prepend-inner-icon="mdi-lock-outline"
|
prepend-inner-icon="mdi-cellphone-android"
|
||||||
|
required
|
||||||
rounded="lg"
|
rounded="lg"
|
||||||
type="password"
|
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -106,6 +103,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { HTTPError } from 'ky'
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import BrandAvatar from '@/components/BrandAvatar.vue'
|
import BrandAvatar from '@/components/BrandAvatar.vue'
|
||||||
@@ -114,28 +112,75 @@ import { useAppStore } from '@/stores/app'
|
|||||||
const store = useAppStore()
|
const store = useAppStore()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
const account = ref('0912-345-678')
|
const account = ref('')
|
||||||
const password = ref('')
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const lineLoading = ref(false)
|
const lineLoading = ref(false)
|
||||||
|
const errorMessage = ref('')
|
||||||
|
|
||||||
function handleLogin () {
|
/** 後端要求純數字、09 開頭、共 10 碼;允許使用者輸入時夾雜連字號或空白 */
|
||||||
if (!account.value) return
|
function normalizePhone (value: string) {
|
||||||
loading.value = true
|
return value.replace(/\D/g, '')
|
||||||
setTimeout(() => {
|
|
||||||
store.login(account.value)
|
|
||||||
loading.value = false
|
|
||||||
router.push('/')
|
|
||||||
}, 1000)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function _handleLineLogin () {
|
interface ErrorResponseBody {
|
||||||
lineLoading.value = true
|
/** 一般錯誤格式,例如 401 手機門號不存在 */
|
||||||
setTimeout(() => {
|
message?: string
|
||||||
store.login('0912-345-678') // LINE 模擬登入預設手機
|
/** 驗證失敗時(400)後端走 ASP.NET Core 的 ProblemDetails 格式 */
|
||||||
lineLoading.value = false
|
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('/')
|
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>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { computed, ref } from 'vue'
|
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 {
|
export interface UserProfile {
|
||||||
name: string
|
name: string
|
||||||
@@ -198,6 +202,8 @@ function createDefaultRadarData () {
|
|||||||
|
|
||||||
export const useAppStore = defineStore('app', () => {
|
export const useAppStore = defineStore('app', () => {
|
||||||
const isLoggedIn = ref(false)
|
const isLoggedIn = ref(false)
|
||||||
|
/** 登入取得的使用者識別碼,課程建議等 API 需要此值查詢 */
|
||||||
|
const userID = ref('')
|
||||||
const userProfile = ref<UserProfile>(createDefaultUserProfile())
|
const userProfile = ref<UserProfile>(createDefaultUserProfile())
|
||||||
const selfAssessment = ref<SelfAssessment>(createDefaultSelfAssessment())
|
const selfAssessment = ref<SelfAssessment>(createDefaultSelfAssessment())
|
||||||
const appointments = ref<Appointment[]>(createDefaultAppointments())
|
const appointments = ref<Appointment[]>(createDefaultAppointments())
|
||||||
@@ -224,13 +230,26 @@ export const useAppStore = defineStore('app', () => {
|
|||||||
return Math.round((done / keys.length) * 100)
|
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
|
isLoggedIn.value = true
|
||||||
userProfile.value.phone = phone
|
userID.value = response.userID
|
||||||
|
userProfile.value.phone = response.phone
|
||||||
|
userProfile.value.name = response.userName
|
||||||
}
|
}
|
||||||
|
|
||||||
function logout () {
|
function logout () {
|
||||||
isLoggedIn.value = false
|
isLoggedIn.value = false
|
||||||
|
userID.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateProfile (profile: Partial<UserProfile>) {
|
function updateProfile (profile: Partial<UserProfile>) {
|
||||||
@@ -374,6 +393,7 @@ export const useAppStore = defineStore('app', () => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
isLoggedIn,
|
isLoggedIn,
|
||||||
|
userID,
|
||||||
userProfile,
|
userProfile,
|
||||||
selfAssessment,
|
selfAssessment,
|
||||||
appointments,
|
appointments,
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ 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'
|
||||||
@@ -152,11 +153,16 @@ export const useCourseStore = defineStore('course', () => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const [groupValue, morningValue, noonValue, nightValue] = await Promise.all([
|
// 真實後端:course/group 需要 userID,course/morning、noon、night 需要 group,
|
||||||
fetchGroup(),
|
// 兩者有相依順序,因此無法四支平行呼叫(與 doc/課程建議API規格.md 的示意圖不同,
|
||||||
fetchMorning(),
|
// 以 doc/課程建議_API_前端使用說明_20260806.md 的建議測試流程為準)
|
||||||
fetchNoon(),
|
const { userID } = useAppStore()
|
||||||
fetchNight(),
|
const groupValue = await fetchGroup(userID)
|
||||||
|
|
||||||
|
const [morningValue, noonValue, nightValue] = await Promise.all([
|
||||||
|
fetchMorning(groupValue),
|
||||||
|
fetchNoon(groupValue),
|
||||||
|
fetchNight(groupValue),
|
||||||
])
|
])
|
||||||
|
|
||||||
group.value = groupValue
|
group.value = groupValue
|
||||||
|
|||||||
@@ -1,30 +1,48 @@
|
|||||||
import { fileURLToPath, URL } from 'node:url'
|
import { fileURLToPath, URL } from 'node:url'
|
||||||
import Vue from '@vitejs/plugin-vue'
|
import Vue from '@vitejs/plugin-vue'
|
||||||
import { defineConfig } from 'vite'
|
import { defineConfig, loadEnv } from 'vite'
|
||||||
import Vuetify, { transformAssetUrls } from 'vite-plugin-vuetify'
|
import Vuetify, { transformAssetUrls } from 'vite-plugin-vuetify'
|
||||||
|
|
||||||
// https://vitejs.dev/config/
|
// https://vitejs.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig(({ mode }) => {
|
||||||
plugins: [
|
// 只在建置設定內讀取(不進 client bundle):後端目前沒有開放 CORS,
|
||||||
Vue({
|
// 本機開發改用 dev server 轉發 /api,避開瀏覽器的跨網域限制。
|
||||||
template: { transformAssetUrls },
|
// 在 .env.local 設定 API_PROXY_TARGET(例如內網後端位址)即可啟用;
|
||||||
}),
|
// 未設定時維持原行為(前端走 VITE_USE_MOCK 的模擬資料)。
|
||||||
// https://github.com/vuetifyjs/vuetify-loader/tree/master/packages/vite-plugin#readme
|
const env = loadEnv(mode, process.cwd(), '')
|
||||||
Vuetify({
|
const apiProxyTarget = env.API_PROXY_TARGET
|
||||||
autoImport: true,
|
|
||||||
styles: {
|
return {
|
||||||
configFile: 'src/styles/settings.scss',
|
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)),
|
||||||
},
|
},
|
||||||
}),
|
extensions: ['.js', '.json', '.jsx', '.mjs', '.ts', '.tsx', '.vue'],
|
||||||
],
|
|
||||||
define: { 'process.env': {} },
|
|
||||||
resolve: {
|
|
||||||
alias: {
|
|
||||||
'@': fileURLToPath(new URL('src', import.meta.url)),
|
|
||||||
},
|
},
|
||||||
extensions: ['.js', '.json', '.jsx', '.mjs', '.ts', '.tsx', '.vue'],
|
server: {
|
||||||
},
|
port: 3678,
|
||||||
server: {
|
proxy: apiProxyTarget
|
||||||
port: 3678,
|
? {
|
||||||
},
|
'/api': {
|
||||||
|
target: apiProxyTarget,
|
||||||
|
changeOrigin: true,
|
||||||
|
rewrite: (path: string) => path.replace(/^\/api/, ''),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user