feat: 後端API

This commit is contained in:
skytek_xinliang
2026-08-06 14:34:20 +08:00
parent db6fe95a11
commit 60c4a414a3
8 changed files with 230 additions and 74 deletions
+15 -1
View File
@@ -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
+8
View File
@@ -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>()
}
+44 -12
View File
@@ -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))
}
+13
View File
@@ -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
}
+77 -32
View File
@@ -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.databody 已被消耗,
// 不能再呼叫 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>
+22 -2
View File
@@ -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,
+11 -5
View File
@@ -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 需要 userIDcourse/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
+40 -22
View File
@@ -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,
},
}
})