chore: OXC Formatter

This commit is contained in:
skytek_xinliang
2026-08-13 15:41:12 +08:00
parent 5377b872d8
commit 8f5fa15d3a
40 changed files with 2392 additions and 3009 deletions
-1
View File
@@ -1,6 +1,5 @@
.cachebro
.eslintcache
node_modules
dist
+4 -4
View File
@@ -26,13 +26,13 @@ Run commands from `app/citizen-frontend` unless noted otherwise.
- `pnpm build`: run type-checking and production build.
- `pnpm build-only`: build the Vite app without type-checking.
- `pnpm type-check`: run `vue-tsc`.
- `pnpm lint`: run ESLint.
- `pnpm lint:fix`: apply ESLint auto-fixes.
- `pnpm format`: format source files with OXC Formatter.
- `pnpm format:check`: check source formatting with OXC Formatter.
- `pnpm preview`: preview the production build locally.
## Coding Style & Naming Conventions
Use TypeScript, Vue 3 Composition API, and `<script setup>` for Vue components. Follow the existing Vuetify component style and route/page organization. Use 2-space indentation, single quotes, no semicolons, and trailing commas where the linter expects them.
Use TypeScript, Vue 3 Composition API, and `<script setup>` for Vue components. Follow the existing Vuetify component style and route/page organization. Use 2-space indentation, single quotes, no semicolons, and trailing commas as enforced by OXC Formatter.
Name Vue pages and components descriptively, matching existing patterns such as `self-assessment.vue`, `DefaultLayout.vue`, and Pinia stores under `src/stores/`.
@@ -41,7 +41,7 @@ Name Vue pages and components descriptively, matching existing patterns such as
No dedicated test suite is currently configured. For changes, run at minimum:
```sh
pnpm lint
pnpm format:check
pnpm type-check
```
+5
View File
@@ -0,0 +1,5 @@
{
"$schema": "./node_modules/oxfmt/configuration_schema.json",
"printWidth": 80,
"ignorePatterns": [".vscode/**", "dist/**"]
}
+3 -2
View File
@@ -1,6 +1,7 @@
{
"recommendations": [
"vuetifyjs.vuetify-vscode",
"vue.volar"
"vue.volar",
"oxc.oxc-vscode"
]
}
}
+5
View File
@@ -0,0 +1,5 @@
{
"oxc.fmt.configPath": ".oxfmtrc.json",
"editor.defaultFormatter": "oxc.oxc-vscode",
"editor.formatOnSave": true
}
+4 -1
View File
@@ -1,15 +1,18 @@
# Project Rules
## General
- Follow the existing code style and patterns.
- Use pnpm for running project commands.
- Keep code in TypeScript unless migration is required.
## Stack
- Framework: Vue 3 + Vite
- UI Library: Vuetify
- Enabled Features: ESLint, Pinia, Vue I18n, Vue Router
- Enabled Features: OXC Formatter, Pinia, Vue I18n, Vue Router
## UI Construction
- Prefer Vuetify built-in UI components and utility classes when building pages.
- Use custom CSS only when Vuetify components or utilities cannot express the required layout, spacing, color, or interaction clearly.
+3 -3
View File
@@ -3,11 +3,11 @@
interface ImportMetaEnv {
/** 後端 API 的 base URL,未設定時走 Vite dev server 的 /api */
readonly VITE_API_BASE_URL?: string
readonly VITE_API_BASE_URL?: string;
/** 設為 'false' 才會真正打後端,其餘情況一律使用前端模擬資料 */
readonly VITE_USE_MOCK?: string
readonly VITE_USE_MOCK?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv
readonly env: ImportMetaEnv;
}
-13
View File
@@ -1,13 +0,0 @@
import vuetify from 'eslint-config-vuetify'
export default vuetify(
{
ts: true,
},
{
rules: {
'vue/script-indent': 'off',
'vue/html-self-closing': 'off',
},
},
)
+4 -5
View File
@@ -1,16 +1,16 @@
{
"name": "citizen-frontend",
"version": "0.0.0",
"private": true,
"type": "module",
"version": "0.0.0",
"scripts": {
"dev": "vite",
"build": "run-p type-check \"build-only {@}\" --",
"preview": "vite preview",
"build-only": "vite build",
"type-check": "vue-tsc --build --force",
"lint": "eslint",
"lint:fix": "eslint --fix --cache --cache-location .eslintcache"
"format": "oxfmt",
"format:check": "oxfmt --check"
},
"dependencies": {
"@mdi/font": "7.4.47",
@@ -26,9 +26,8 @@
"@types/node": "^24.12.0",
"@vitejs/plugin-vue": "^6.0.5",
"@vue/tsconfig": "^0.9.0",
"eslint": "^9.39.4",
"eslint-config-vuetify": "^4.3.4",
"npm-run-all2": "^8.0.4",
"oxfmt": "^0.62.0",
"sass-embedded": "^1.98.0",
"typescript": "~5.9.3",
"vite": "^8.0.0",
+5 -5
View File
@@ -1,8 +1,8 @@
import type { LoginRequest, LoginResponse } from './types'
import { api } from './client'
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>()
export function login(phone: string): Promise<LoginResponse> {
const body: LoginRequest = { phone };
return api.post("v1/auth/login", { json: body }).json<LoginResponse>();
}
+3 -3
View File
@@ -1,4 +1,4 @@
import ky from 'ky'
import ky from "ky";
/**
* 全站共用的 HTTP client。
@@ -6,7 +6,7 @@ import ky from 'ky'
*/
export const api = ky.create({
// prefix 同時接受相對路徑(/api)與絕對網址,兩種部署情境都涵蓋
prefix: import.meta.env.VITE_API_BASE_URL || '/api',
prefix: import.meta.env.VITE_API_BASE_URL || "/api",
timeout: 10_000,
retry: { limit: 2 },
})
});
+34 -24
View File
@@ -1,5 +1,11 @@
import type { CourseList, CourseListParams, CourseRow, FitnessGroup, YesNo } from './types'
import { api } from './client'
import type {
CourseList,
CourseListParams,
CourseRow,
FitnessGroup,
YesNo,
} from "./types";
import { api } from "./client";
/**
* 課程建議的 5 支 API。
@@ -11,54 +17,58 @@ import { api } from './client'
* 與 doc/課程建議API規格.md 記載的 `{ day, morning, noon, night }` 不同,故在此轉換。
*/
interface CourseListApiRow {
weekdayCode: string
weekdayText: string
morning: string
noon: string
night: string
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
function toDay(weekdayCode: string): number {
const code = Number(weekdayCode);
return code === 0 ? 7 : code;
}
/** 未勾選的時段後端回傳空字串,轉為 undefined 以符合 CourseRow 的選填語意 */
function toCourseRow (raw: CourseListApiRow): 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 fetchGroup(userID: string): Promise<FitnessGroup> {
return api
.get("course/group", { searchParams: { userID } })
.json<FitnessGroup>();
}
/** 取得是否納入早上時段 */
export function fetchMorning (group: FitnessGroup): Promise<YesNo> {
return api.get('course/morning', { searchParams: { group } }).json<YesNo>()
export function fetchMorning(group: FitnessGroup): Promise<YesNo> {
return api.get("course/morning", { searchParams: { group } }).json<YesNo>();
}
/** 取得是否納入中午時段 */
export function fetchNoon (group: FitnessGroup): Promise<YesNo> {
return api.get('course/noon', { searchParams: { group } }).json<YesNo>()
export function fetchNoon(group: FitnessGroup): Promise<YesNo> {
return api.get("course/noon", { searchParams: { group } }).json<YesNo>();
}
/** 取得是否納入晚上時段 */
export function fetchNight (group: FitnessGroup): Promise<YesNo> {
return api.get('course/night', { searchParams: { group } }).json<YesNo>()
export function fetchNight(group: FitnessGroup): Promise<YesNo> {
return api.get("course/night", { searchParams: { group } }).json<YesNo>();
}
/** 以組別與時段條件取得一週課表 */
export async function fetchCourseList (params: CourseListParams): Promise<CourseList> {
export async function fetchCourseList(
params: CourseListParams,
): Promise<CourseList> {
const rows = await api
.get('course/list', { searchParams: { ...params } })
.json<CourseListApiRow[]>()
.get("course/list", { searchParams: { ...params } })
.json<CourseListApiRow[]>();
return rows.map(row => toCourseRow(row))
return rows.map((row) => toCourseRow(row));
}
+17 -17
View File
@@ -4,42 +4,42 @@
*/
/** 體適能組別 */
export type FitnessGroup = 'low' | 'medium' | 'high'
export type FitnessGroup = "low" | "medium" | "high";
/** 後端以 Y/N 字串表示布林值 */
export type YesNo = 'Y' | 'N'
export type YesNo = "Y" | "N";
/** 課表的三個時段 */
export type TimeSlot = 'morning' | 'noon' | 'night'
export type TimeSlot = "morning" | "noon" | "night";
/** 課表的一天;未被選取的時段其欄位為 undefined */
export interface CourseRow {
/** 1 = 週一 … 7 = 週日 */
day: number
morning?: string
noon?: string
night?: string
day: number;
morning?: string;
noon?: string;
night?: string;
}
export type CourseList = CourseRow[]
export type CourseList = CourseRow[];
/** 取得課表所需的查詢參數 */
export interface CourseListParams {
group: FitnessGroup
morning: YesNo
noon: YesNo
night: YesNo
group: FitnessGroup;
morning: YesNo;
noon: YesNo;
night: YesNo;
}
/** 登入請求:僅需手機門號 */
export interface LoginRequest {
phone: string
phone: string;
}
/** 登入成功回應,userID 供後續課程 API 查詢使用 */
export interface LoginResponse {
userID: string
userName: string
phone: string
group: FitnessGroup
userID: string;
userName: string;
phone: string;
group: FitnessGroup;
}
@@ -1,11 +1,14 @@
<script setup lang="ts">
withDefaults(defineProps<{
size?: number | string
iconSize?: number | string
}>(), {
size: 80,
iconSize: 48,
})
withDefaults(
defineProps<{
size?: number | string;
iconSize?: number | string;
}>(),
{
size: 80,
iconSize: 48,
},
);
</script>
<template>
@@ -118,43 +118,43 @@
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { computed } from "vue";
interface RadarData {
strength: number
balance: number
flexibility: number
agility: number
cardio: number
strength: number;
balance: number;
flexibility: number;
agility: number;
cardio: number;
}
const { radarData } = defineProps<{
radarData: RadarData
}>()
radarData: RadarData;
}>();
const centerX = 60
const centerY = 60
const radius = 45
const centerX = 60;
const centerY = 60;
const radius = 45;
function getAxisEndPoint (index: number, pointRadius = radius) {
const angle = -Math.PI / 2 + (index * 2 * Math.PI) / 5
function getAxisEndPoint(index: number, pointRadius = radius) {
const angle = -Math.PI / 2 + (index * 2 * Math.PI) / 5;
return {
x: centerX + pointRadius * Math.cos(angle),
y: centerY + pointRadius * Math.sin(angle),
}
};
}
function getPolygonPoints (percent: number) {
const pointRadius = (percent / 100) * radius
const points = []
function getPolygonPoints(percent: number) {
const pointRadius = (percent / 100) * radius;
const points = [];
for (let index = 0; index < 5; index++) {
const point = getAxisEndPoint(index, pointRadius)
points.push(`${point.x},${point.y}`)
const point = getAxisEndPoint(index, pointRadius);
points.push(`${point.x},${point.y}`);
}
return points.join(' ')
return points.join(" ");
}
const userDataCoordArray = computed(() => {
@@ -164,17 +164,19 @@ const userDataCoordArray = computed(() => {
radarData.balance,
radarData.flexibility,
radarData.agility,
]
];
return values.map((value, index) => {
const pointRadius = (Math.max(5, Math.min(100, value)) / 100) * radius
return getAxisEndPoint(index, pointRadius)
})
})
const pointRadius = (Math.max(5, Math.min(100, value)) / 100) * radius;
return getAxisEndPoint(index, pointRadius);
});
});
const userDataPoints = computed(() => {
return userDataCoordArray.value.map(point => `${point.x},${point.y}`).join(' ')
})
return userDataCoordArray.value
.map((point) => `${point.x},${point.y}`)
.join(" ");
});
</script>
<style scoped>
@@ -5,12 +5,18 @@
:to="to"
>
<div class="d-flex align-center">
<v-avatar class="mr-3 text-white elevation-1" :color="iconColor" size="48">
<v-avatar
class="mr-3 text-white elevation-1"
:color="iconColor"
size="48"
>
<v-icon :icon="icon" />
</v-avatar>
<div>
<div class="font-weight-bold text-subtitle-2 text-grey-darken-4">{{ title }}</div>
<div class="font-weight-bold text-subtitle-2 text-grey-darken-4">
{{ title }}
</div>
<div class="text-caption text-grey">{{ description }}</div>
</div>
@@ -22,10 +28,10 @@
<script setup lang="ts">
defineProps<{
to: string
icon: string
iconColor: string
title: string
description: string
}>()
to: string;
icon: string;
iconColor: string;
title: string;
description: string;
}>();
</script>
+17 -14
View File
@@ -1,26 +1,29 @@
<script setup lang="ts">
import { computed } from 'vue'
import { createQrCodeMatrix } from '@/utils/qrcode'
import { computed } from "vue";
import { createQrCodeMatrix } from "@/utils/qrcode";
const props = withDefaults(defineProps<{
value: string
size?: number
}>(), {
size: 180,
})
const props = withDefaults(
defineProps<{
value: string;
size?: number;
}>(),
{
size: 180,
},
);
const quietZone = 4
const quietZone = 4;
const qrCode = computed(() => createQrCodeMatrix(props.value))
const viewBoxSize = computed(() => qrCode.value.size + quietZone * 2)
const qrCode = computed(() => createQrCodeMatrix(props.value));
const viewBoxSize = computed(() => qrCode.value.size + quietZone * 2);
const darkModules = computed(() => {
return qrCode.value.modules.flatMap((row, y) => {
return row
.map((dark, x) => ({ dark, x: x + quietZone, y: y + quietZone }))
.filter(module => module.dark)
})
})
.filter((module) => module.dark);
});
});
</script>
<template>
@@ -6,7 +6,9 @@
<v-app-bar-nav-icon variant="text" @click="drawer = !drawer" />
</template>
<v-app-bar-title class="font-weight-bold flex-grow-1"> 運動玩轉健康力 </v-app-bar-title>
<v-app-bar-title class="font-weight-bold flex-grow-1">
運動玩轉健康力
</v-app-bar-title>
<template #append>
<div class="d-flex align-center">
@@ -19,7 +21,10 @@
variant="text"
@click="isLargeText = !isLargeText"
>
<v-icon :color="isLargeText ? 'accent' : 'white'" icon="mdi-format-size" />
<v-icon
:color="isLargeText ? 'accent' : 'white'"
icon="mdi-format-size"
/>
</v-btn>
</div>
</template>
@@ -58,7 +63,9 @@
</v-avatar>
</template>
<v-list-item-subtitle class="text-white-50">未登入訪客</v-list-item-subtitle>
<v-list-item-subtitle class="text-white-50"
>未登入訪客</v-list-item-subtitle
>
</v-list-item>
<v-divider />
@@ -71,7 +78,12 @@
<v-list-item-title>大字體模式</v-list-item-title>
<template #append>
<v-switch v-model="isLargeText" color="secondary" density="compact" hide-details />
<v-switch
v-model="isLargeText"
color="secondary"
density="compact"
hide-details
/>
</template>
</v-list-item>
@@ -191,7 +203,9 @@
tag="div"
>
<v-btn to="/" value="home">
<v-icon :color="getBottomNavIconColor('home')">mdi-view-dashboard</v-icon>
<v-icon :color="getBottomNavIconColor('home')"
>mdi-view-dashboard</v-icon
>
<span class="bottom-nav-text">首頁</span>
</v-btn>
@@ -204,12 +218,16 @@
</v-btn>
<v-btn v-if="store.isLoggedIn" to="/checkin" value="checkin">
<v-icon :color="getBottomNavIconColor('checkin')">mdi-qrcode-scan</v-icon>
<v-icon :color="getBottomNavIconColor('checkin')"
>mdi-qrcode-scan</v-icon
>
<span class="bottom-nav-text">量測</span>
</v-btn>
<v-btn v-if="store.isLoggedIn" to="/appointment" value="appointment">
<v-icon :color="getBottomNavIconColor('appointment')">mdi-calendar-check</v-icon>
<v-icon :color="getBottomNavIconColor('appointment')"
>mdi-calendar-check</v-icon
>
<span class="bottom-nav-text">預約</span>
</v-btn>
</v-bottom-navigation>
@@ -217,7 +235,9 @@
<!-- 隱私授權對話框 -->
<v-dialog v-model="showPrivacy" max-width="500">
<v-card class="rounded-xl">
<v-card-title class="bg-primary text-white font-weight-bold d-flex align-center">
<v-card-title
class="bg-primary text-white font-weight-bold d-flex align-center"
>
<v-icon class="mr-2" icon="mdi-shield-check" />
個資同意與資料授權
</v-card-title>
@@ -234,7 +254,7 @@
</ul>
<p class="text-caption text-grey">
授權狀態{{ store.userProfile.consent ? '已同意授權' : '未授權' }}
授權狀態{{ store.userProfile.consent ? "已同意授權" : "未授權" }}
</p>
</v-card-text>
@@ -254,7 +274,9 @@
<!-- 緊急聯絡人對話框 -->
<v-dialog v-model="showEmergency" max-width="500">
<v-card class="rounded-xl">
<v-card-title class="bg-primary text-white font-weight-bold d-flex align-center">
<v-card-title
class="bg-primary text-white font-weight-bold d-flex align-center"
>
<v-icon class="mr-2" icon="mdi-alert-circle-outline" />
緊急聯絡資訊
</v-card-title>
@@ -294,46 +316,47 @@
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAppStore } from '@/stores/app'
import { ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useAppStore } from "@/stores/app";
const store = useAppStore()
const router = useRouter()
const route = useRoute()
const store = useAppStore();
const router = useRouter();
const route = useRoute();
const drawer = ref(false)
const activeTab = ref('home')
const showPrivacy = ref(false)
const showEmergency = ref(false)
const drawer = ref(false);
const activeTab = ref("home");
const showPrivacy = ref(false);
const showEmergency = ref(false);
// 無障礙狀態 (可持久化儲存於 localStorage)
const isLargeText = ref(localStorage.getItem('mode-large-text') === 'true')
const isLargeText = ref(localStorage.getItem("mode-large-text") === "true");
watch(isLargeText, newVal => {
localStorage.setItem('mode-large-text', String(newVal))
})
watch(isLargeText, (newVal) => {
localStorage.setItem("mode-large-text", String(newVal));
});
// 同步底部導航狀態與當前路由
watch(
() => route.path,
path => {
if (path === '/') activeTab.value = 'home'
else if (path.startsWith('/self-assessment')) activeTab.value = 'self-assessment'
else if (path.startsWith('/appointment')) activeTab.value = 'appointment'
else if (path.startsWith('/checkin')) activeTab.value = 'checkin'
(path) => {
if (path === "/") activeTab.value = "home";
else if (path.startsWith("/self-assessment"))
activeTab.value = "self-assessment";
else if (path.startsWith("/appointment")) activeTab.value = "appointment";
else if (path.startsWith("/checkin")) activeTab.value = "checkin";
},
{ immediate: true },
)
);
function getBottomNavIconColor (value: string) {
return activeTab.value === value ? 'secondary' : 'secondary-darken-1'
function getBottomNavIconColor(value: string) {
return activeTab.value === value ? "secondary" : "secondary-darken-1";
}
function handleLogout () {
store.logout()
router.push('/login')
drawer.value = false
function handleLogout() {
store.logout();
router.push("/login");
drawer.value = false;
}
</script>
@@ -34,7 +34,12 @@
],
"outcome": {
"label": "解決問題",
"items": ["肌肉流失。", "平衡能力下降。", "心肺儲備不足。", "跌倒風險升高。"]
"items": [
"肌肉流失。",
"平衡能力下降。",
"心肺儲備不足。",
"跌倒風險升高。"
]
}
},
"medium": {
@@ -88,7 +93,12 @@
],
"profile": {
"summary": "根據研究,老化並不會停止,只能透過長期運動與良好飲食延後功能下降。",
"items": ["功能儲備高。", "肌力與平衡佳。", "跌倒風險低。", "屬於成功老化族群。"]
"items": [
"功能儲備高。",
"肌力與平衡佳。",
"跌倒風險低。",
"屬於成功老化族群。"
]
},
"prescription": [
{
+12 -12
View File
@@ -1,5 +1,5 @@
import type { FitnessGroup } from '@/api/types'
import courseDetail from './course-detail.json'
import type { FitnessGroup } from "@/api/types";
import courseDetail from "./course-detail.json";
/**
* 各體適能組別的靜態內容,資料來源為 course-detail.json。
@@ -12,23 +12,23 @@ import courseDetail from './course-detail.json'
/** 一天的運動時間建議;minutes 為 null 代表原表時間欄留空(休息日) */
export interface DurationRow {
day: number
content: string
minutes: number | null
day: number;
content: string;
minutes: number | null;
}
/** 精準運動處方建議的單一項目 */
export interface PrescriptionItem {
title: string
detail: string
title: string;
detail: string;
}
export interface CourseDetail {
duration: DurationRow[]
profile: { summary: string, items: string[] }
prescription: PrescriptionItem[]
duration: DurationRow[];
profile: { summary: string; items: string[] };
prescription: PrescriptionItem[];
/** label 三組各異:低=解決問題、中=任務、高=目標 */
outcome: { label: string, items: string[] }
outcome: { label: string; items: string[] };
}
export const COURSE_DETAIL = courseDetail as Record<FitnessGroup, CourseDetail>
export const COURSE_DETAIL = courseDetail as Record<FitnessGroup, CourseDetail>;
+7 -7
View File
@@ -5,19 +5,19 @@
*/
// Composables
import { createApp } from 'vue'
import { createApp } from "vue";
// Plugins
import { registerPlugins } from '@/plugins'
import { registerPlugins } from "@/plugins";
// Components
import App from './App.vue'
import App from "./App.vue";
// Styles
import '@/styles/fonts.css'
import "@/styles/fonts.css";
const app = createApp(App)
const app = createApp(App);
registerPlugins(app)
registerPlugins(app);
app.mount('#app')
app.mount("#app");
+82 -45
View File
@@ -10,19 +10,36 @@
</v-card>
<!-- 活動分類選擇 -->
<v-tabs v-model="filterTab" class="mb-4 bg-white rounded-xl elevation-1" color="primary" grow>
<v-tabs
v-model="filterTab"
class="mb-4 bg-white rounded-xl elevation-1"
color="primary"
grow
>
<v-tab value="all">全部活動</v-tab>
<v-tab value="registered">我的報名</v-tab>
</v-tabs>
<!-- 活動列表 -->
<div v-if="filteredAppointments.length === 0" class="text-center py-12">
<v-icon class="mb-3" color="grey-lighten-1" icon="mdi-calendar-blank" size="64" />
<div class="text-title-small text-grey font-weight-bold">目前沒有相關活動</div>
<v-icon
class="mb-3"
color="grey-lighten-1"
icon="mdi-calendar-blank"
size="64"
/>
<div class="text-title-small text-grey font-weight-bold">
目前沒有相關活動
</div>
</div>
<v-row v-else>
<v-col v-for="apt in filteredAppointments" :key="apt.id" class="mb-4" cols="12">
<v-col
v-for="apt in filteredAppointments"
:key="apt.id"
class="mb-4"
cols="12"
>
<v-card class="rounded-xl elevation-3" color="surface">
<!-- 卡片頂部狀態條 -->
<v-card-item class="bg-teal-lighten-5 py-4">
@@ -44,12 +61,19 @@
</v-card-item>
<v-card-text>
<h2 class="text-title-large font-weight-bold text-grey-darken-4 mb-4">
<h2
class="text-title-large font-weight-bold text-grey-darken-4 mb-4"
>
{{ apt.title }}
</h2>
<div class="d-flex align-start mb-2">
<v-icon class="mr-2" color="primary" icon="mdi-calendar-clock" size="18" />
<v-icon
class="mr-2"
color="primary"
icon="mdi-calendar-clock"
size="18"
/>
<div class="text-body-2 text-grey-darken-3">
<span class="font-weight-bold">{{ apt.date }}</span>
@@ -63,13 +87,19 @@
</div> -->
<div class="d-flex align-start mb-3">
<v-icon class="mr-2" color="primary" icon="mdi-human-queue" size="18" />
<v-icon
class="mr-2"
color="primary"
icon="mdi-human-queue"
size="18"
/>
<div class="text-body-2 text-grey-darken-3">
剩餘名額<span
class="font-weight-bold"
:class="apt.slots < 10 ? 'text-error' : 'text-success'"
>{{ apt.slots }}</span>
>{{ apt.slots }}</span
>
</div>
</div>
@@ -107,7 +137,7 @@
variant="flat"
@click="handleRegister(apt.id)"
>
{{ apt.slots === 0 ? '名額已滿' : '線上報名活動' }}
{{ apt.slots === 0 ? "名額已滿" : "線上報名活動" }}
</v-btn>
</template>
@@ -165,11 +195,15 @@
</v-card-title>
<v-card-text class="pa-6">
<h3 class="text-subtitle-1 font-weight-bold mb-1 text-grey-darken-4">
<h3
class="text-subtitle-1 font-weight-bold mb-1 text-grey-darken-4"
>
{{ selectedApt?.title }}
</h3>
<p class="text-caption text-grey mb-4">請在現場合對處出示此條碼進行報到</p>
<p class="text-caption text-grey mb-4">
請在現場合對處出示此條碼進行報到
</p>
<div
class="qrcode-wrapper mx-auto mb-4 elevation-3 rounded-lg pa-3 bg-white border d-flex justify-center align-center"
@@ -178,7 +212,8 @@
</div>
<div class="text-caption text-grey-darken-2 font-weight-bold mb-4">
姓名: {{ store.userProfile.name }} 先生 | 電話: {{ store.userProfile.phone }}
姓名: {{ store.userProfile.name }} 先生 | 電話:
{{ store.userProfile.phone }}
</div>
<!-- 模擬現場合對處高齡測試快捷鍵 -->
@@ -213,66 +248,68 @@
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import Layout from '@/components/layout/DefaultLayout.vue'
import QRCode from '@/components/QRCode.vue'
import { type Appointment, useAppStore } from '@/stores/app'
import { computed, ref } from "vue";
import Layout from "@/components/layout/DefaultLayout.vue";
import QRCode from "@/components/QRCode.vue";
import { type Appointment, useAppStore } from "@/stores/app";
const store = useAppStore()
const filterTab = ref('all')
const store = useAppStore();
const filterTab = ref("all");
const qrcodeDialog = ref(false)
const selectedApt = ref<Appointment | null>(null)
const qrcodeDialog = ref(false);
const selectedApt = ref<Appointment | null>(null);
const filteredAppointments = computed(() => {
if (filterTab.value === 'registered') {
return store.appointments.filter(a => a.status === '已報名' || a.status === '已報到')
if (filterTab.value === "registered") {
return store.appointments.filter(
(a) => a.status === "已報名" || a.status === "已報到",
);
}
return store.appointments
})
return store.appointments;
});
const selectedCheckinPayload = computed(() => {
if (!selectedApt.value) {
return 'https://hp.local/c'
return "https://hp.local/c";
}
return `https://hp.local/c/${selectedApt.value.id}`
})
return `https://hp.local/c/${selectedApt.value.id}`;
});
function getStatusColor (status: string) {
function getStatusColor(status: string) {
switch (status) {
case '未報名': {
return 'grey-darken-1'
case "未報名": {
return "grey-darken-1";
}
case '已報名': {
return 'secondary'
case "已報名": {
return "secondary";
}
case '已報到': {
return 'success'
case "已報到": {
return "success";
}
default: {
return 'grey'
return "grey";
}
}
}
function handleRegister (id: string) {
store.registerAppointment(id)
function handleRegister(id: string) {
store.registerAppointment(id);
}
function handleCancel (id: string) {
store.cancelAppointment(id)
function handleCancel(id: string) {
store.cancelAppointment(id);
}
function showCheckinQRCode (apt: Appointment) {
selectedApt.value = apt
qrcodeDialog.value = true
function showCheckinQRCode(apt: Appointment) {
selectedApt.value = apt;
qrcodeDialog.value = true;
}
function simulateOnSiteCheckin () {
function simulateOnSiteCheckin() {
if (selectedApt.value) {
store.checkinAppointment(selectedApt.value.id)
qrcodeDialog.value = false
store.checkinAppointment(selectedApt.value.id);
qrcodeDialog.value = false;
}
}
</script>
+152 -96
View File
@@ -10,31 +10,50 @@
</v-card>
<!-- 報到活動資訊 -->
<v-card v-if="activeAppointment" class="rounded-xl elevation-3 mb-6" color="surface">
<v-card
v-if="activeAppointment"
class="rounded-xl elevation-3 mb-6"
color="surface"
>
<v-card-text>
<div class="d-flex align-center justify-space-between mb-3">
<span class="text-title-small font-weight-bold text-primary">
<v-icon class="mr-1" icon="mdi-map-marker-radius" />
現場檢測站{{ activeAppointment.location.split(' ')[0] }}
現場檢測站{{ activeAppointment.location.split(" ")[0] }}
</span>
<v-chip class="font-weight-black" color="success" size="small" variant="flat">
<v-chip
class="font-weight-black"
color="success"
size="small"
variant="flat"
>
已報到
</v-chip>
</div>
<h2 class="text-title-large font-weight-black text-grey-darken-4 mb-2">
<h2
class="text-title-large font-weight-black text-grey-darken-4 mb-2"
>
{{ activeAppointment.title }}
</h2>
<div class="text-title-small text-grey">日期{{ activeAppointment.date }}</div>
<div class="text-title-small text-grey">
日期{{ activeAppointment.date }}
</div>
<!-- 量測總進度條 -->
<div class="mt-4">
<div class="d-flex justify-space-between align-center mb-1">
<span class="text-title-small font-weight-bold text-grey-darken-2">測量站點完成度</span>
<span class="text-title-small font-weight-bold text-grey-darken-2"
>測量站點完成度</span
>
<span class="text-title-small font-weight-bold text-primary">{{ completedCount }} / {{ totalCount }} ({{ progressPercent }}%)</span>
<span class="text-title-small font-weight-bold text-primary"
>{{ completedCount }} / {{ totalCount }} ({{
progressPercent
}}%)</span
>
</div>
<v-progress-linear
@@ -50,20 +69,31 @@
</v-card>
<!-- 未報到提示 -->
<v-card v-else class="rounded-xl elevation-3 pa-6 text-center mb-6" color="surface">
<v-card
v-else
class="rounded-xl elevation-3 pa-6 text-center mb-6"
color="surface"
>
<v-icon class="mb-3" color="warning" icon="mdi-qrcode-scan" size="64" />
<h3 class="text-title-medium font-weight-bold mb-2">尚未在活動現場合對報到</h3>
<h3 class="text-title-medium font-weight-bold mb-2">
尚未在活動現場合對報到
</h3>
<p class="text-body-medium text-grey mb-4">
請先前往活動預約頁面出示您的報到條碼給現場志工進行核對
</p>
<v-btn class="rounded-lg" color="primary" to="/appointment">前往查看預約活動</v-btn>
<v-btn class="rounded-lg" color="primary" to="/appointment"
>前往查看預約活動</v-btn
>
</v-card>
<!-- 快捷測試功能 ( Demo 非常友善) -->
<div v-if="activeAppointment && completedCount < totalCount" class="d-flex justify-center">
<div
v-if="activeAppointment && completedCount < totalCount"
class="d-flex justify-center"
>
<v-btn
class="rounded-lg font-weight-bold text-white"
color="secondary"
@@ -75,13 +105,23 @@
<!-- 11 個量測關卡列表 -->
<div v-if="activeAppointment" class="mb-6">
<h3 class="text-title-medium font-weight-black text-primary px-4 mb-3">關卡列表</h3>
<h3 class="text-title-medium font-weight-black text-primary px-4 mb-3">
關卡列表
</h3>
<v-row>
<v-col v-for="(station, key) in store.measurements" :key="key" class="mb-2" cols="12">
<v-col
v-for="(station, key) in store.measurements"
:key="key"
class="mb-2"
cols="12"
>
<v-card
class="rounded-xl elevation-2 cursor-pointer transition-card"
:class="{ 'border-success': station.completed, 'border-grey': !station.completed }"
:class="{
'border-success': station.completed,
'border-grey': !station.completed,
}"
:to="`/checkin/${key}`"
>
<div class="d-flex align-center justify-space-between pa-4">
@@ -92,7 +132,9 @@
size="40"
>
<v-icon
:icon="station.completed ? 'mdi-check' : getStationIcon(key)"
:icon="
station.completed ? 'mdi-check' : getStationIcon(key)
"
size="20"
/>
</v-avatar>
@@ -100,14 +142,23 @@
<div>
<div
class="font-weight-bold text-body-large"
:class="station.completed ? 'text-success' : 'text-grey-darken-4'"
:class="
station.completed
? 'text-success'
: 'text-grey-darken-4'
"
>
{{ station.name }}
</div>
<!-- 已完成顯示數值 -->
<div v-if="station.completed" class="text-title-small text-grey-darken-2">
<div
v-if="station.completed"
class="text-title-small text-grey-darken-2"
>
{{ getMeasurementSummary(key, station.values) }}
<span class="ml-2 text-grey-lighten-1">| {{ station.time?.split(' ')[1] }}</span>
<span class="ml-2 text-grey-lighten-1"
>| {{ station.time?.split(" ")[1] }}</span
>
</div>
<!-- 未完成顯示待測 -->
<div v-else class="text-title-small text-grey-darken-1">
@@ -146,7 +197,9 @@
<v-icon color="white" icon="mdi-check-all" size="40" />
</v-avatar>
<h2 class="text-h5 font-weight-black text-success mb-2">報告計算完成</h2>
<h2 class="text-h5 font-weight-black text-success mb-2">
報告計算完成
</h2>
<p class="text-body-2 text-grey-darken-2 mb-6">
您的健康力五維雷達圖與同齡區間的參考值以及健康力總分已成功計算更新
@@ -168,148 +221,151 @@
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import Layout from '@/components/layout/DefaultLayout.vue'
import { useAppStore } from '@/stores/app'
import { computed, ref } from "vue";
import { useRouter } from "vue-router";
import Layout from "@/components/layout/DefaultLayout.vue";
import { useAppStore } from "@/stores/app";
const store = useAppStore()
const router = useRouter()
const reportSuccessDialog = ref(false)
const store = useAppStore();
const router = useRouter();
const reportSuccessDialog = ref(false);
// 尋找已報到的活動
const activeAppointment = computed(() => {
return store.appointments.find(a => a.status === '已報到')
})
return store.appointments.find((a) => a.status === "已報到");
});
const completedCount = computed(() => store.completedStationsCount)
const totalCount = computed(() => store.totalStationsCount)
const progressPercent = computed(() => store.progressPercent)
const completedCount = computed(() => store.completedStationsCount);
const totalCount = computed(() => store.totalStationsCount);
const progressPercent = computed(() => store.progressPercent);
function getStationIcon (code: string) {
function getStationIcon(code: string) {
switch (code) {
case 'BloodPressure': {
return 'mdi-heart-flash'
case "BloodPressure": {
return "mdi-heart-flash";
}
case 'BodyFat': {
return 'mdi-scale-bathroom'
case "BodyFat": {
return "mdi-scale-bathroom";
}
case 'GripStrength': {
return 'mdi-hand-back-left'
case "GripStrength": {
return "mdi-hand-back-left";
}
case 'Sit5x': {
return 'mdi-human-male-height'
case "Sit5x": {
return "mdi-human-male-height";
}
case 'Sit30s': {
return 'mdi-chair-school'
case "Sit30s": {
return "mdi-chair-school";
}
case 'StepTest': {
return 'mdi-run-fast'
case "StepTest": {
return "mdi-run-fast";
}
case 'ArmTest': {
return 'mdi-arm-flex'
case "ArmTest": {
return "mdi-arm-flex";
}
case 'BackTest': {
return 'mdi-human-stretching'
case "BackTest": {
return "mdi-human-stretching";
}
case 'SitReachTest': {
return 'mdi-human-stretching'
case "SitReachTest": {
return "mdi-human-stretching";
}
case 'UpGo8ft': {
return 'mdi-navigation'
case "UpGo8ft": {
return "mdi-navigation";
}
case 'OneLegTest': {
return 'mdi-scale-balance'
case "OneLegTest": {
return "mdi-scale-balance";
}
default: {
return 'mdi-checkbox-blank-circle-outline'
return "mdi-checkbox-blank-circle-outline";
}
}
}
function getMeasurementSummary (code: string, vals: Record<string, any>) {
function getMeasurementSummary(code: string, vals: Record<string, any>) {
switch (code) {
case 'BloodPressure': {
return `收縮壓: ${vals.systolic} / 舒張壓: ${vals.diastolic} mmHg | 心率: ${vals.heartRate} bpm`
case "BloodPressure": {
return `收縮壓: ${vals.systolic} / 舒張壓: ${vals.diastolic} mmHg | 心率: ${vals.heartRate} bpm`;
}
case 'BodyFat': {
return `BMI: ${vals.bmi} | 體脂率: ${vals.bodyFatPct}%`
case "BodyFat": {
return `BMI: ${vals.bmi} | 體脂率: ${vals.bodyFatPct}%`;
}
case 'GripStrength': {
return `左手: ${vals.leftHand} kg | 右手: ${vals.rightHand} kg`
case "GripStrength": {
return `左手: ${vals.leftHand} kg | 右手: ${vals.rightHand} kg`;
}
case 'Sit5x': {
return `耗時: ${vals.durationSeconds}`
case "Sit5x": {
return `耗時: ${vals.durationSeconds}`;
}
case 'Sit30s': {
return `次數: ${vals.count}`
case "Sit30s": {
return `次數: ${vals.count}`;
}
case 'StepTest': {
return `踏步: ${vals.count}`
case "StepTest": {
return `踏步: ${vals.count}`;
}
case 'ArmTest': {
return `屈舉: ${vals.count}`
case "ArmTest": {
return `屈舉: ${vals.count}`;
}
case 'BackTest': {
return `左手: ${vals.leftDistanceCm} cm | 右手: ${vals.rightDistanceCm} cm`
case "BackTest": {
return `左手: ${vals.leftDistanceCm} cm | 右手: ${vals.rightDistanceCm} cm`;
}
case 'SitReachTest': {
return `伸展: ${vals.distanceCm} cm`
case "SitReachTest": {
return `伸展: ${vals.distanceCm} cm`;
}
case 'UpGo8ft': {
return `耗時: ${vals.durationSeconds}`
case "UpGo8ft": {
return `耗時: ${vals.durationSeconds}`;
}
case 'OneLegTest': {
return `左腳: ${vals.leftSeconds} 秒 | 右腳: ${vals.rightSeconds}`
case "OneLegTest": {
return `左腳: ${vals.leftSeconds} 秒 | 右腳: ${vals.rightSeconds}`;
}
default: {
return '數據登錄成功'
return "數據登錄成功";
}
}
}
// 模擬一鍵完成所有測量 (對 Demo 极為有用)
function simulateAllMeasurements () {
function simulateAllMeasurements() {
// 自動將剩餘的 9 站填入合理健康的隨機值
const m = store.measurements
const m = store.measurements;
if (!m.GripStrength.completed) {
store.saveMeasurement('GripStrength', { leftHand: 32, rightHand: 34 })
store.saveMeasurement("GripStrength", { leftHand: 32, rightHand: 34 });
}
if (!m.Sit5x.completed) {
store.saveMeasurement('Sit5x', { durationSeconds: 8.5 })
store.saveMeasurement("Sit5x", { durationSeconds: 8.5 });
}
if (!m.Sit30s.completed) {
store.saveMeasurement('Sit30s', { count: 18 })
store.saveMeasurement("Sit30s", { count: 18 });
}
if (!m.StepTest.completed) {
store.saveMeasurement('StepTest', { count: 92 })
store.saveMeasurement("StepTest", { count: 92 });
}
if (!m.ArmTest.completed) {
store.saveMeasurement('ArmTest', { count: 20 })
store.saveMeasurement("ArmTest", { count: 20 });
}
if (!m.BackTest.completed) {
store.saveMeasurement('BackTest', { leftDistanceCm: 2, rightDistanceCm: 3 })
store.saveMeasurement("BackTest", {
leftDistanceCm: 2,
rightDistanceCm: 3,
});
}
if (!m.SitReachTest.completed) {
store.saveMeasurement('SitReachTest', { distanceCm: 14 })
store.saveMeasurement("SitReachTest", { distanceCm: 14 });
}
if (!m.UpGo8ft.completed) {
store.saveMeasurement('UpGo8ft', { durationSeconds: 5.8 })
store.saveMeasurement("UpGo8ft", { durationSeconds: 5.8 });
}
if (!m.OneLegTest.completed) {
store.saveMeasurement('OneLegTest', { leftSeconds: 24, rightSeconds: 26 })
store.saveMeasurement("OneLegTest", { leftSeconds: 24, rightSeconds: 26 });
}
}
function generateReport () {
store.generateFinalReport()
reportSuccessDialog.value = true
function generateReport() {
store.generateFinalReport();
reportSuccessDialog.value = true;
}
function goToDashboard () {
reportSuccessDialog.value = false
router.push('/')
function goToDashboard() {
reportSuccessDialog.value = false;
router.push("/");
}
</script>
+279 -216
View File
@@ -14,22 +14,36 @@
</v-btn>
<!-- 關卡詳細卡片 -->
<v-card v-if="station" class="rounded-xl elevation-3 mb-6" color="surface">
<v-card
v-if="station"
class="rounded-xl elevation-3 mb-6"
color="surface"
>
<v-card-item class="bg-primary text-white py-4">
<div class="d-flex align-center mb-4">
<v-avatar class="mr-3 text-white elevation-1 flex-shrink-0" color="accent" size="48">
<v-avatar
class="mr-3 text-white elevation-1 flex-shrink-0"
color="accent"
size="48"
>
<v-icon :icon="station.icon" size="24" />
</v-avatar>
<div>
<h1 class="text-title-large font-weight-black">{{ station.name }}</h1>
<h1 class="text-title-large font-weight-black">
{{ station.name }}
</h1>
<span class="text-title-medium text-white"> {{ currentStationIndex }} / 11 </span>
<span class="text-title-medium text-white"
> {{ currentStationIndex }} / 11 </span
>
</div>
</div>
<div class="checkin-qr-panel rounded-lg pa-4 text-center">
<div class="text-title-small font-weight-bold mb-2">請出示 QR Code 給量測機掃描</div>
<div class="text-title-small font-weight-bold mb-2">
請出示 QR Code 給量測機掃描
</div>
<div class="d-flex justify-center mb-2">
<QRCode :size="160" :value="pidQrPayload" />
@@ -88,32 +102,57 @@
{{ machineStatusText }}
</v-btn>
<div class="text-title-small text-grey-darken-1">{{ machineStatusHint }}</div>
<div class="text-title-small text-grey-darken-1">
{{ machineStatusHint }}
</div>
</div>
</div>
</v-card-text>
</v-card>
<h3 class="text-title-medium font-weight-bold text-grey-darken-3 mb-3">量測數據</h3>
<h3
class="text-title-medium font-weight-bold text-grey-darken-3 mb-3"
>
量測數據
</h3>
<v-row>
<v-col v-for="field in station.fields" :key="field.key" class="mb-3" cols="12">
<v-card class="rounded-lg border-accent-light" color="surface" variant="flat">
<v-card-text class="pa-4 d-flex justify-space-between align-center">
<v-col
v-for="field in station.fields"
:key="field.key"
class="mb-3"
cols="12"
>
<v-card
class="rounded-lg border-accent-light"
color="surface"
variant="flat"
>
<v-card-text
class="pa-4 d-flex justify-space-between align-center"
>
<div>
<div class="text-body-large font-weight-bold text-grey-darken-3">
<div
class="text-body-large font-weight-bold text-grey-darken-3"
>
{{ field.label }}
</div>
<div class="text-title-small text-grey">由量測機上傳後自動更新</div>
<div class="text-title-small text-grey">
由量測機上傳後自動更新
</div>
</div>
<div class="text-right">
<div class="text-title-large font-weight-black text-primary">
<div
class="text-title-large font-weight-black text-primary"
>
{{ displayMeasurementValue(field.key) }}
</div>
<div class="text-title-small text-grey">{{ field.unit }}</div>
<div class="text-title-small text-grey">
{{ field.unit }}
</div>
</div>
</v-card-text>
</v-card>
@@ -138,8 +177,14 @@
</v-card>
<!-- 參考值與說明卡片 -->
<v-card v-if="station" class="rounded-xl elevation-2 pa-4" color="surface">
<div class="d-flex align-center mb-3 text-primary font-weight-bold text-subtitle-2">
<v-card
v-if="station"
class="rounded-xl elevation-2 pa-4"
color="surface"
>
<div
class="d-flex align-center mb-3 text-primary font-weight-bold text-subtitle-2"
>
<v-icon class="mr-1" icon="mdi-heart-plus-outline" />
65 歲以上高齡正常參考標準
</div>
@@ -155,390 +200,408 @@
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import Layout from '@/components/layout/DefaultLayout.vue'
import QRCode from '@/components/QRCode.vue'
import { useAppStore } from '@/stores/app'
import { computed, onBeforeUnmount, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import Layout from "@/components/layout/DefaultLayout.vue";
import QRCode from "@/components/QRCode.vue";
import { useAppStore } from "@/stores/app";
const route = useRoute()
const router = useRouter()
const store = useAppStore()
const route = useRoute();
const router = useRouter();
const store = useAppStore();
type MachineStatus = 'waiting' | 'measuring' | 'updated'
type MachineStatus = "waiting" | "measuring" | "updated";
const code = computed(() => route.params.code as string)
const machineStatus = ref<MachineStatus>('waiting')
const mockTimers: number[] = []
const code = computed(() => route.params.code as string);
const machineStatus = ref<MachineStatus>("waiting");
const mockTimers: number[] = [];
// 各個測量站點的詳細規格定義
const stationsSpec: Record<
string,
{
name: string
icon: string
description: string
reference: string
name: string;
icon: string;
description: string;
reference: string;
fields: Array<{
key: string
label: string
unit: string
placeholder: string
defaultValue: number
}>
key: string;
label: string;
unit: string;
placeholder: string;
defaultValue: number;
}>;
}
> = {
BloodPressure: {
name: '血壓與心率量測',
icon: 'mdi-heart-flash',
name: "血壓與心率量測",
icon: "mdi-heart-flash",
description:
'請在放鬆狀態下靜坐 5 分鐘,手臂平放於桌面上,將袖帶綁在手肘上方兩公分處,按下測量鈕。檢測員請錄入血壓儀數值。',
"請在放鬆狀態下靜坐 5 分鐘,手臂平放於桌面上,將袖帶綁在手肘上方兩公分處,按下測量鈕。檢測員請錄入血壓儀數值。",
reference:
'• 收縮壓 (高壓): 120-139 mmHg (正常偏高),超過 140 mmHg 為高血壓警告。\n• 舒張壓 (低壓): 80-89 mmHg,超過 90 mmHg 為高血壓警告。\n• 靜止心率: 60-100 bpm。',
"• 收縮壓 (高壓): 120-139 mmHg (正常偏高),超過 140 mmHg 為高血壓警告。\n• 舒張壓 (低壓): 80-89 mmHg,超過 90 mmHg 為高血壓警告。\n• 靜止心率: 60-100 bpm。",
fields: [
{
key: 'systolic',
label: '收縮壓 (高壓)',
unit: 'mmHg',
placeholder: '如: 125',
key: "systolic",
label: "收縮壓 (高壓)",
unit: "mmHg",
placeholder: "如: 125",
defaultValue: 120,
},
{
key: 'diastolic',
label: '舒張壓 (低壓)',
unit: 'mmHg',
placeholder: '如: 80',
key: "diastolic",
label: "舒張壓 (低壓)",
unit: "mmHg",
placeholder: "如: 80",
defaultValue: 80,
},
{ key: 'heartRate', label: '安靜心率', unit: 'bpm', placeholder: '如: 72', defaultValue: 70 },
{
key: "heartRate",
label: "安靜心率",
unit: "bpm",
placeholder: "如: 72",
defaultValue: 70,
},
],
},
BodyFat: {
name: '體組成分析 (BMI & 體脂)',
icon: 'mdi-scale-bathroom',
description: '請脫鞋站在體脂計上,雙手握緊金屬把手。本站測量體重、BMI 與體脂肪率。',
name: "體組成分析 (BMI & 體脂)",
icon: "mdi-scale-bathroom",
description:
"請脫鞋站在體脂計上,雙手握緊金屬把手。本站測量體重、BMI 與體脂肪率。",
reference:
'• 高齡者 BMI 正常區間: 20.0 - 26.9 (高齡適度豐腴有助於功能儲備)。\n• 體脂肪率正常區間:\n - 男性: 15% - 25% (超過 25% 為肥胖)\n - 女性: 20% - 30% (超過 30% 為肥胖)',
"• 高齡者 BMI 正常區間: 20.0 - 26.9 (高齡適度豐腴有助於功能儲備)。\n• 體脂肪率正常區間:\n - 男性: 15% - 25% (超過 25% 為肥胖)\n - 女性: 20% - 30% (超過 30% 為肥胖)",
fields: [
{
key: 'bmi',
label: 'BMI 身體質量指數',
unit: 'kg/m²',
placeholder: '如: 22.5',
key: "bmi",
label: "BMI 身體質量指數",
unit: "kg/m²",
placeholder: "如: 22.5",
defaultValue: 22,
},
{
key: 'bodyFatPct',
label: '體脂肪率',
unit: '%',
placeholder: '如: 24.0',
key: "bodyFatPct",
label: "體脂肪率",
unit: "%",
placeholder: "如: 24.0",
defaultValue: 25,
},
],
},
GripStrength: {
name: '左右手握力測驗 (肌力)',
icon: 'mdi-hand-back-left',
name: "左右手握力測驗 (肌力)",
icon: "mdi-hand-back-left",
description:
'受測者自然站立,手持握力器呈 90 度或自然下垂,全力緊握。左右手各測兩次,取最大值錄入。評估上肢最大肌力。',
"受測者自然站立,手持握力器呈 90 度或自然下垂,全力緊握。左右手各測兩次,取最大值錄入。評估上肢最大肌力。",
reference:
'• 高齡男性正常標準: 大於 28 公斤。\n• 高齡女性正常標準: 大於 18 公斤。\n• 數值過低可能代表肌少症前期,需加強抗阻力運動。',
"• 高齡男性正常標準: 大於 28 公斤。\n• 高齡女性正常標準: 大於 18 公斤。\n• 數值過低可能代表肌少症前期,需加強抗阻力運動。",
fields: [
{
key: 'leftHand',
label: '左手最大握力',
unit: 'kg',
placeholder: '如: 30',
key: "leftHand",
label: "左手最大握力",
unit: "kg",
placeholder: "如: 30",
defaultValue: 0,
},
{
key: 'rightHand',
label: '右手最大握力',
unit: 'kg',
placeholder: '如: 32',
key: "rightHand",
label: "右手最大握力",
unit: "kg",
placeholder: "如: 32",
defaultValue: 0,
},
],
},
Sit5x: {
name: '5次坐站測試 (下肢肌力)',
icon: 'mdi-human-male-height',
name: "5次坐站測試 (下肢肌力)",
icon: "mdi-human-male-height",
description:
'受測者雙手抱胸,坐在無扶手椅子上。聽到口令後,以最快速度完成「站起-坐下」5 次。記錄完成的秒數。',
"受測者雙手抱胸,坐在無扶手椅子上。聽到口令後,以最快速度完成「站起-坐下」5 次。記錄完成的秒數。",
reference:
'• 65 歲以上長者正常標準: 小於 12 秒完成。\n• 若超過 15 秒,表示下肢肌力明顯不足,跌倒風險增高,建議加入股四頭肌訓練。',
"• 65 歲以上長者正常標準: 小於 12 秒完成。\n• 若超過 15 秒,表示下肢肌力明顯不足,跌倒風險增高,建議加入股四頭肌訓練。",
fields: [
{
key: 'durationSeconds',
label: '5次坐站完成時間',
unit: '秒',
placeholder: '如: 9.8',
key: "durationSeconds",
label: "5次坐站完成時間",
unit: "秒",
placeholder: "如: 9.8",
defaultValue: 0,
},
],
},
Sit30s: {
name: '30秒椅子坐立 (肌肉耐力)',
icon: 'mdi-chair-school',
name: "30秒椅子坐立 (肌肉耐力)",
icon: "mdi-chair-school",
description:
'雙手抱胸交叉於肩前,坐在高度約 43 公分的椅子中間。聽到開始口令後,30 秒內全力完成「完全站立且直立-坐回椅子」的次數。',
"雙手抱胸交叉於肩前,坐在高度約 43 公分的椅子中間。聽到開始口令後,30 秒內全力完成「完全站立且直立-坐回椅子」的次數。",
reference:
'• 65-69 歲正常標準:\n - 男性: 12 - 18 次\n - 女性: 11 - 17 次\n• 此項目量測長者下肢肌肉耐力,是日常行走和樓梯攀爬的基礎。',
"• 65-69 歲正常標準:\n - 男性: 12 - 18 次\n - 女性: 11 - 17 次\n• 此項目量測長者下肢肌肉耐力,是日常行走和樓梯攀爬的基礎。",
fields: [
{ key: 'count', label: '30秒內坐立次數', unit: '次', placeholder: '如: 14', defaultValue: 0 },
{
key: "count",
label: "30秒內坐立次數",
unit: "次",
placeholder: "如: 14",
defaultValue: 0,
},
],
},
StepTest: {
name: '2分鐘原地抬膝踏步 (心肺耐力)',
icon: 'mdi-run-fast',
name: "2分鐘原地抬膝踏步 (心肺耐力)",
icon: "mdi-run-fast",
description:
'量測大轉子與髕骨中點高度,在牆上做記號。受測者聽到開始後,原地踏步,大腿必須抬高至記號高度。計算 2 分鐘內「右膝」抬起的次數。',
"量測大轉子與髕骨中點高度,在牆上做記號。受測者聽到開始後,原地踏步,大腿必須抬高至記號高度。計算 2 分鐘內「右膝」抬起的次數。",
reference:
'• 65-69 歲正常標準:\n - 男性: 86 - 116 次\n - 女性: 73 - 107 次\n• 評估長者有氧心肺適能,是維持長途行走體力的指標。',
"• 65-69 歲正常標準:\n - 男性: 86 - 116 次\n - 女性: 73 - 107 次\n• 評估長者有氧心肺適能,是維持長途行走體力的指標。",
fields: [
{
key: 'count',
label: '2分鐘右膝抬起次數',
unit: '次',
placeholder: '如: 80',
key: "count",
label: "2分鐘右膝抬起次數",
unit: "次",
placeholder: "如: 80",
defaultValue: 0,
},
],
},
ArmTest: {
name: '30秒肱二頭肌手臂屈舉 (上肢肌耐力)',
icon: 'mdi-arm-flex',
name: "30秒肱二頭肌手臂屈舉 (上肢肌耐力)",
icon: "mdi-arm-flex",
description:
'坐在椅子上,優勢手持啞鈴 (男性 8 磅 / 女性 5 磅) 自然下垂。聽到開始後,30 秒內以最快速度完成前臂彎舉與放下的次數。',
"坐在椅子上,優勢手持啞鈴 (男性 8 磅 / 女性 5 磅) 自然下垂。聽到開始後,30 秒內以最快速度完成前臂彎舉與放下的次數。",
reference:
'• 65-69 歲正常標準:\n - 男性 (8磅): 15 - 21 次\n - 女性 (5磅): 13 - 19 次\n• 評估上肢日常拿取重物、做家事所需的肌肉耐力。',
"• 65-69 歲正常標準:\n - 男性 (8磅): 15 - 21 次\n - 女性 (5磅): 13 - 19 次\n• 評估上肢日常拿取重物、做家事所需的肌肉耐力。",
fields: [
{
key: 'count',
label: '30秒手臂屈舉次數',
unit: '次',
placeholder: '如: 16',
key: "count",
label: "30秒手臂屈舉次數",
unit: "次",
placeholder: "如: 16",
defaultValue: 0,
},
],
},
BackTest: {
name: '抓背測驗 (上肢關節柔軟度)',
icon: 'mdi-human-stretching',
name: "抓背測驗 (上肢關節柔軟度)",
icon: "mdi-human-stretching",
description:
'一隻手從肩上向後下方伸,另一隻手從腰部往後上方伸,雙手中指儘量靠近並重疊。量測兩中指間距離 (重疊為正,未觸碰為負)。',
"一隻手從肩上向後下方伸,另一隻手從腰部往後上方伸,雙手中指儘量靠近並重疊。量測兩中指間距離 (重疊為正,未觸碰為負)。",
reference:
'• 65-69 歲正常標準:\n - 男性: -16 至 -2 公分\n - 女性: -8 至 +3 公分\n• 用於評估肩膀關節的靈活性,攸關穿衣、梳頭等日常自理動作。',
"• 65-69 歲正常標準:\n - 男性: -16 至 -2 公分\n - 女性: -8 至 +3 公分\n• 用於評估肩膀關節的靈活性,攸關穿衣、梳頭等日常自理動作。",
fields: [
{
key: 'leftDistanceCm',
label: '左手在上時兩指間距 (可為負值)',
unit: '公分',
placeholder: '如: -5 或 2',
key: "leftDistanceCm",
label: "左手在上時兩指間距 (可為負值)",
unit: "公分",
placeholder: "如: -5 或 2",
defaultValue: 0,
},
{
key: 'rightDistanceCm',
label: '右手在上時兩指間距 (可為負值)',
unit: '公分',
placeholder: '如: -3 或 4',
key: "rightDistanceCm",
label: "右手在上時兩指間距 (可為負值)",
unit: "公分",
placeholder: "如: -3 或 4",
defaultValue: 0,
},
],
},
SitReachTest: {
name: '坐姿體前彎 (下肢與腰部柔軟度)',
icon: 'mdi-human-stretching',
name: "坐姿體前彎 (下肢與腰部柔軟度)",
icon: "mdi-human-stretching",
description:
'坐在椅子邊緣,一腳伸直,腳尖勾起,另一腳彎曲平放。吸氣後呼氣,雙手重疊慢慢向前伸觸碰腳尖。測量中指端與腳尖距離。',
"坐在椅子邊緣,一腳伸直,腳尖勾起,另一腳彎曲平放。吸氣後呼氣,雙手重疊慢慢向前伸觸碰腳尖。測量中指端與腳尖距離。",
reference:
'• 65-69 歲正常標準:\n - 男性: -8 至 +8 公分\n - 女性: -3 至 +12 公分\n• 用於評估大腿後側肌群與腰部的柔軟度,柔軟度佳可有效減緩腰酸背痛並預防拉傷。',
"• 65-69 歲正常標準:\n - 男性: -8 至 +8 公分\n - 女性: -3 至 +12 公分\n• 用於評估大腿後側肌群與腰部的柔軟度,柔軟度佳可有效減緩腰酸背痛並預防拉傷。",
fields: [
{
key: 'distanceCm',
label: '前彎中指與腳尖距離',
unit: '公分',
placeholder: '如: 5 (超過腳尖為正)',
key: "distanceCm",
label: "前彎中指與腳尖距離",
unit: "公分",
placeholder: "如: 5 (超過腳尖為正)",
defaultValue: 0,
},
],
},
UpGo8ft: {
name: '2.44公尺起立繞物 (動態平衡與敏捷)',
icon: 'mdi-navigation',
name: "2.44公尺起立繞物 (動態平衡與敏捷)",
icon: "mdi-navigation",
description:
'坐在椅子上,前方 2.44 公尺 (8英尺) 處放置角錐。聽到開始口令後,起身快步繞過角錐並走回椅子坐下。記錄完成秒數。',
"坐在椅子上,前方 2.44 公尺 (8英尺) 處放置角錐。聽到開始口令後,起身快步繞過角錐並走回椅子坐下。記錄完成秒數。",
reference:
'• 65 歲以上長者正常標準: 小於 8.5 秒完成。\n• 若大於 9.0 秒,代表敏捷度與動態平衡功能衰退,日常過馬路或避開障礙物時跌倒風險顯著升高。',
"• 65 歲以上長者正常標準: 小於 8.5 秒完成。\n• 若大於 9.0 秒,代表敏捷度與動態平衡功能衰退,日常過馬路或避開障礙物時跌倒風險顯著升高。",
fields: [
{
key: 'durationSeconds',
label: '起立繞物完成時間',
unit: '秒',
placeholder: '如: 6.2',
key: "durationSeconds",
label: "起立繞物完成時間",
unit: "秒",
placeholder: "如: 6.2",
defaultValue: 0,
},
],
},
OneLegTest: {
name: '開眼單足立 (靜態平衡力)',
icon: 'mdi-scale-balance',
name: "開眼單足立 (靜態平衡力)",
icon: "mdi-scale-balance",
description:
'受測者雙手叉腰,一腳抬離地面,開始計時。當支撐腳移動、雙手離開腰部或懸空腳觸地時停止。左右腳各測一次,取最大秒數。',
"受測者雙手叉腰,一腳抬離地面,開始計時。當支撐腳移動、雙手離開腰部或懸空腳觸地時停止。左右腳各測一次,取最大秒數。",
reference:
'• 65 歲以上正常標準: 大於 15 秒。\n• 低於 10 秒代表靜態平衡力嚴重退化,在日常站立、穿褲子或滑倒時缺乏瞬間反應力。',
"• 65 歲以上正常標準: 大於 15 秒。\n• 低於 10 秒代表靜態平衡力嚴重退化,在日常站立、穿褲子或滑倒時缺乏瞬間反應力。",
fields: [
{
key: 'leftSeconds',
label: '左腳站立時間',
unit: '秒',
placeholder: '如: 18',
key: "leftSeconds",
label: "左腳站立時間",
unit: "秒",
placeholder: "如: 18",
defaultValue: 0,
},
{
key: 'rightSeconds',
label: '右腳站立時間',
unit: '秒',
placeholder: '如: 20',
key: "rightSeconds",
label: "右腳站立時間",
unit: "秒",
placeholder: "如: 20",
defaultValue: 0,
},
],
},
}
};
const station = computed(() => stationsSpec[code.value])
const currentMeasurement = computed(() => store.measurements[code.value])
const userPid = computed(() => `PID-${store.userProfile.phone.replace(/\D/g, '')}`)
const pidQrPayload = computed(() => `hp:pid:${userPid.value}`)
const station = computed(() => stationsSpec[code.value]);
const currentMeasurement = computed(() => store.measurements[code.value]);
const userPid = computed(
() => `PID-${store.userProfile.phone.replace(/\D/g, "")}`,
);
const pidQrPayload = computed(() => `hp:pid:${userPid.value}`);
const machineStatusText = computed(() => {
if (machineStatus.value === 'waiting') {
return '等待掃描QR Code'
if (machineStatus.value === "waiting") {
return "等待掃描QR Code";
}
if (machineStatus.value === 'measuring') {
return '量測完成,資料上傳中'
if (machineStatus.value === "measuring") {
return "量測完成,資料上傳中";
}
return '伺服器已同步數據'
})
return "伺服器已同步數據";
});
const machineStatusHint = computed(() => {
if (machineStatus.value === 'waiting') {
return '按下開始模擬後,量測機會掃描 QR Code 取得使用者 PID。'
if (machineStatus.value === "waiting") {
return "按下開始模擬後,量測機會掃描 QR Code 取得使用者 PID。";
}
if (machineStatus.value === 'measuring') {
return '設備正在將本站量測值送往後端伺服器,請稍候。'
if (machineStatus.value === "measuring") {
return "設備正在將本站量測值送往後端伺服器,請稍候。";
}
return `更新時間:${currentMeasurement.value?.time ?? '--'}`
})
return `更新時間:${currentMeasurement.value?.time ?? "--"}`;
});
// 計算當前關卡在 11 關中的順序
const currentStationIndex = computed(() => {
const keys = Object.keys(stationsSpec)
return keys.indexOf(code.value) + 1
})
const keys = Object.keys(stationsSpec);
return keys.indexOf(code.value) + 1;
});
watch(
code,
newCode => {
(newCode) => {
if (stationsSpec[newCode]) {
resetMockMeasurementFlow()
resetMockMeasurementFlow();
}
},
{ immediate: true },
)
);
onBeforeUnmount(() => {
clearMockTimers()
})
clearMockTimers();
});
function displayMeasurementValue (fieldKey: string) {
if (machineStatus.value !== 'updated') {
return '--'
function displayMeasurementValue(fieldKey: string) {
if (machineStatus.value !== "updated") {
return "--";
}
return currentMeasurement.value?.values[fieldKey] ?? '--'
return currentMeasurement.value?.values[fieldKey] ?? "--";
}
function goToNextStation () {
const keys = Object.keys(stationsSpec)
const currentIndex = keys.indexOf(code.value)
function goToNextStation() {
const keys = Object.keys(stationsSpec);
const currentIndex = keys.indexOf(code.value);
if (currentIndex !== -1 && currentIndex < keys.length - 1) {
const nextCode = keys[currentIndex + 1]
router.push(`/checkin/${nextCode}`)
const nextCode = keys[currentIndex + 1];
router.push(`/checkin/${nextCode}`);
} else {
router.push('/checkin')
router.push("/checkin");
}
}
function handleMachineStatusClick () {
startMockMeasurementFlow(code.value)
function handleMachineStatusClick() {
startMockMeasurementFlow(code.value);
}
function startMockMeasurementFlow (stationCode: string) {
clearMockTimers()
machineStatus.value = 'measuring'
function startMockMeasurementFlow(stationCode: string) {
clearMockTimers();
machineStatus.value = "measuring";
mockTimers.push(
window.setTimeout(() => {
store.saveMeasurement(stationCode, createMockMeasurementValues(stationCode))
machineStatus.value = 'updated'
store.saveMeasurement(
stationCode,
createMockMeasurementValues(stationCode),
);
machineStatus.value = "updated";
}, 1500),
)
);
}
function resetMockMeasurementFlow () {
clearMockTimers()
machineStatus.value = 'waiting'
function resetMockMeasurementFlow() {
clearMockTimers();
machineStatus.value = "waiting";
}
function clearMockTimers () {
function clearMockTimers() {
while (mockTimers.length > 0) {
window.clearTimeout(mockTimers.pop())
window.clearTimeout(mockTimers.pop());
}
}
function createMockMeasurementValues (stationCode: string) {
function createMockMeasurementValues(stationCode: string) {
switch (stationCode) {
case 'BloodPressure': {
return { systolic: 128, diastolic: 82, heartRate: 74 }
case "BloodPressure": {
return { systolic: 128, diastolic: 82, heartRate: 74 };
}
case 'BodyFat': {
return { bmi: 23.4, bodyFatPct: 24.8 }
case "BodyFat": {
return { bmi: 23.4, bodyFatPct: 24.8 };
}
case 'GripStrength': {
return { leftHand: 31, rightHand: 34 }
case "GripStrength": {
return { leftHand: 31, rightHand: 34 };
}
case 'Sit5x': {
return { durationSeconds: 8.7 }
case "Sit5x": {
return { durationSeconds: 8.7 };
}
case 'Sit30s': {
return { count: 17 }
case "Sit30s": {
return { count: 17 };
}
case 'StepTest': {
return { count: 94 }
case "StepTest": {
return { count: 94 };
}
case 'ArmTest': {
return { count: 19 }
case "ArmTest": {
return { count: 19 };
}
case 'BackTest': {
return { leftDistanceCm: 2, rightDistanceCm: 3 }
case "BackTest": {
return { leftDistanceCm: 2, rightDistanceCm: 3 };
}
case 'SitReachTest': {
return { distanceCm: 13 }
case "SitReachTest": {
return { distanceCm: 13 };
}
case 'UpGo8ft': {
return { durationSeconds: 5.9 }
case "UpGo8ft": {
return { durationSeconds: 5.9 };
}
case 'OneLegTest': {
return { leftSeconds: 23, rightSeconds: 26 }
case "OneLegTest": {
return { leftSeconds: 23, rightSeconds: 26 };
}
default: {
return {}
return {};
}
}
}
+184 -79
View File
@@ -3,13 +3,24 @@
<template #main>
<!-- 頁面標題刻意縮小把版面讓給下方的組別 -->
<div class="d-flex align-center mb-3">
<v-icon class="mr-2" color="primary" icon="mdi-calendar-week" size="18" />
<v-icon
class="mr-2"
color="primary"
icon="mdi-calendar-week"
size="18"
/>
<span class="text-title-small font-weight-bold text-grey-darken-1">課程建議</span>
<span class="text-title-small font-weight-bold text-grey-darken-1"
>課程建議</span
>
</div>
<!-- 體適能組別本頁所有內容都依它而定因此獨立成一張顯眼的卡片 -->
<v-card class="rounded-xl mb-6 overflow-hidden" elevation="1" variant="flat">
<v-card
class="rounded-xl mb-6 overflow-hidden"
elevation="1"
variant="flat"
>
<v-card-item class="pa-4" :class="`bg-${store.groupMeta.color}`">
<template #prepend>
<v-avatar class="group-avatar" size="60">
@@ -22,7 +33,9 @@
</template>
<template #title>
<span class="text-h5 font-weight-black text-white">{{ store.groupMeta.label }}</span>
<span class="text-h5 font-weight-black text-white">{{
store.groupMeta.label
}}</span>
</template>
</v-card-item>
@@ -61,7 +74,12 @@
<!-- 載入中 -->
<template v-if="store.loading">
<v-skeleton-loader v-for="n in 3" :key="n" class="rounded-xl mb-4" type="article" />
<v-skeleton-loader
v-for="n in 3"
:key="n"
class="rounded-xl mb-4"
type="article"
/>
</template>
<!-- 載入失敗 -->
@@ -75,7 +93,9 @@
{{ store.error }}
<template #append>
<v-btn size="small" variant="text" @click="store.loadAll()">重試</v-btn>
<v-btn size="small" variant="text" @click="store.loadAll()"
>重試</v-btn
>
</template>
</v-alert>
@@ -94,8 +114,15 @@
<template v-else>
<!-- 課程說明對應 doc/課程說明.md -->
<v-card class="rounded-lg elevation-1 mt-4 overflow-hidden">
<v-card-title class="text-title-medium font-weight-bold text-primary d-flex align-center">
<v-icon class="mr-2" color="primary" icon="mdi-book-open-variant" size="20" />
<v-card-title
class="text-title-medium font-weight-bold text-primary d-flex align-center"
>
<v-icon
class="mr-2"
color="primary"
icon="mdi-book-open-variant"
size="20"
/>
課程說明
</v-card-title>
@@ -103,7 +130,9 @@
<v-expansion-panels v-model="openPanels" multiple variant="accordion">
<v-expansion-panel>
<v-expansion-panel-title class="font-weight-bold">族群特徵</v-expansion-panel-title>
<v-expansion-panel-title class="font-weight-bold"
>族群特徵</v-expansion-panel-title
>
<v-expansion-panel-text>
<p class="text-body-medium text-grey-darken-2 mb-3">
@@ -115,7 +144,11 @@
:key="index"
class="d-flex align-start ga-3 mb-2"
>
<v-icon color="secondary" :icon="`mdi-numeric-${index + 1}-circle`" size="20" />
<v-icon
color="secondary"
:icon="`mdi-numeric-${index + 1}-circle`"
size="20"
/>
<div class="text-body-medium">{{ item }}</div>
</div>
</v-expansion-panel-text>
@@ -132,12 +165,18 @@
:key="index"
class="d-flex align-start ga-3 mb-4"
>
<v-icon color="secondary" :icon="`mdi-numeric-${index + 1}-circle`" size="20" />
<v-icon
color="secondary"
:icon="`mdi-numeric-${index + 1}-circle`"
size="20"
/>
<div>
<div class="font-weight-bold">{{ item.title }}</div>
<div class="text-body-medium text-grey-darken-2 mt-1">{{ item.detail }}</div>
<div class="text-body-medium text-grey-darken-2 mt-1">
{{ item.detail }}
</div>
</div>
</div>
</v-expansion-panel-text>
@@ -154,7 +193,11 @@
:key="index"
class="d-flex align-start ga-3 mb-2"
>
<v-icon color="secondary" :icon="`mdi-numeric-${index + 1}-circle`" size="20" />
<v-icon
color="secondary"
:icon="`mdi-numeric-${index + 1}-circle`"
size="20"
/>
<div class="text-body-medium">{{ item }}</div>
</div>
</v-expansion-panel-text>
@@ -164,12 +207,21 @@
<!-- 運動時間建議對應 doc/運動時間建議.png -->
<v-card class="rounded-lg elevation-1 mt-4 overflow-hidden">
<v-expansion-panels v-model="durationTablePanel" flat variant="accordion">
<v-expansion-panels
v-model="durationTablePanel"
flat
variant="accordion"
>
<v-expansion-panel value="duration">
<v-expansion-panel-title
class="course-table-panel-title text-title-medium font-weight-bold text-primary"
>
<v-icon class="mr-2" color="primary" icon="mdi-timer-outline" size="20" />
<v-icon
class="mr-2"
color="primary"
icon="mdi-timer-outline"
size="20"
/>
運動時間建議
</v-expansion-panel-title>
@@ -179,9 +231,15 @@
<v-table class="course-table" density="comfortable">
<thead>
<tr>
<th class="day-col bg-primary text-white text-center">星期</th>
<th class="bg-primary text-white text-center">運動內容</th>
<th class="time-col bg-primary text-white text-center">時間</th>
<th class="day-col bg-primary text-white text-center">
星期
</th>
<th class="bg-primary text-white text-center">
運動內容
</th>
<th class="time-col bg-primary text-white text-center">
時間
</th>
</tr>
</thead>
@@ -191,14 +249,18 @@
:key="row.day"
:class="{ 'bg-teal-lighten-5': row.day === todayDay }"
>
<td class="day-col text-center font-weight-bold text-primary">
<td
class="day-col text-center font-weight-bold text-primary"
>
{{ DAY_NAMES[row.day - 1] }}
</td>
<td class="text-body-medium text-wrap">{{ row.content }}</td>
<td class="text-body-medium text-wrap">
{{ row.content }}
</td>
<td class="time-col text-center text-body-medium">
{{ row.minutes === null ? '—' : `${row.minutes}` }}
{{ row.minutes === null ? "—" : `${row.minutes}` }}
</td>
</tr>
</tbody>
@@ -209,12 +271,21 @@
</v-card>
<v-card class="rounded-lg elevation-1 mt-4 overflow-hidden">
<v-expansion-panels v-model="scheduleTablePanel" flat variant="accordion">
<v-expansion-panels
v-model="scheduleTablePanel"
flat
variant="accordion"
>
<v-expansion-panel value="schedule">
<v-expansion-panel-title
class="course-table-panel-title text-title-medium font-weight-bold text-primary"
>
<v-icon class="mr-2" color="primary" icon="mdi-timer-outline" size="20" />
<v-icon
class="mr-2"
color="primary"
icon="mdi-timer-outline"
size="20"
/>
運動課表
</v-expansion-panel-title>
@@ -224,7 +295,9 @@
<v-table class="course-table" density="comfortable">
<thead>
<tr>
<th class="day-col bg-primary text-white text-center">星期</th>
<th class="day-col bg-primary text-white text-center">
星期
</th>
<th
v-for="slot in visibleSlots"
@@ -244,7 +317,9 @@
:key="row.day"
:class="{ 'bg-teal-lighten-5': row.day === todayDay }"
>
<td class="day-col text-center font-weight-bold text-primary">
<td
class="day-col text-center font-weight-bold text-primary"
>
{{ DAY_NAMES[row.day - 1] }}
</td>
@@ -253,8 +328,13 @@
:key="slot.key"
class="slot-cell text-body-medium text-wrap"
>
<div class="d-flex flex-column flex-md-row align-center ga-1 ga-md-3">
<v-avatar class="slot-avatar flex-shrink-0" rounded="lg">
<div
class="d-flex flex-column flex-md-row align-center ga-1 ga-md-3"
>
<v-avatar
class="slot-avatar flex-shrink-0"
rounded="lg"
>
<v-img
:alt="`${DAY_NAMES[row.day - 1]}${slot.label}課表插圖`"
:src="scheduleCardImage(row.day, slot.key)"
@@ -273,7 +353,10 @@
</v-card>
<v-card class="rounded-lg elevation-1 mt-4 overflow-hidden">
<v-img :alt="`${store.groupMeta.label}課表示意圖`" :src="groupIllustration" />
<v-img
:alt="`${store.groupMeta.label}課表示意圖`"
:src="groupIllustration"
/>
</v-card>
</template>
</template>
@@ -281,102 +364,124 @@
</template>
<script setup lang="ts">
import type { FitnessGroup, TimeSlot, YesNo } from '@/api/types'
import { computed, onMounted, ref } from 'vue'
import highIllustration from '@/assets/course/high.png'
import lowIllustration from '@/assets/course/low.png'
import mediumIllustration from '@/assets/course/medium.png'
import Layout from '@/components/layout/DefaultLayout.vue'
import { COURSE_DETAIL } from '@/data/courseDetail'
import { useCourseStore } from '@/stores/course'
import type { FitnessGroup, TimeSlot, YesNo } from "@/api/types";
import { computed, onMounted, ref } from "vue";
import highIllustration from "@/assets/course/high.png";
import lowIllustration from "@/assets/course/low.png";
import mediumIllustration from "@/assets/course/medium.png";
import Layout from "@/components/layout/DefaultLayout.vue";
import { COURSE_DETAIL } from "@/data/courseDetail";
import { useCourseStore } from "@/stores/course";
const store = useCourseStore()
const store = useCourseStore();
// 運動時間建議與課程說明的靜態內容,依組別對應
const detail = computed(() => COURSE_DETAIL[store.group])
const detail = computed(() => COURSE_DETAIL[store.group]);
// 課程說明預設只展開第一個面板(族群特徵)
const openPanels = ref([0])
const openPanels = ref([0]);
// 兩張表格預設展開,使用者可分別收合以減少頁面長度。
const durationTablePanel = ref<string | null>('duration')
const scheduleTablePanel = ref<string | null>('schedule')
const durationTablePanel = ref<string | null>("duration");
const scheduleTablePanel = ref<string | null>("schedule");
const GROUP_ILLUSTRATIONS: Record<FitnessGroup, string> = {
low: lowIllustration,
medium: mediumIllustration,
high: highIllustration,
}
};
const groupIllustration = computed(() => GROUP_ILLUSTRATIONS[store.group])
const groupIllustration = computed(() => GROUP_ILLUSTRATIONS[store.group]);
// 時段篩選功能尚未開放,UI 先隱藏;之後要開放時把這個常數改回 true 即可
const SHOW_SLOT_FILTER = false
const SHOW_SLOT_FILTER = false;
const DAY_NAMES = ['一', '二', '三', '四', '五', '六', '日']
const DAY_NAMES = ["一", "二", "三", "四", "五", "六", "日"];
// 課表小卡:檔名為「星期-時段」,逐一匯入 21 張圖並依 day/slot 查表取用
const scheduleCardModules = import.meta.glob<{ default: string }>(
'../assets/course/schedule/*.png',
"../assets/course/schedule/*.png",
{ eager: true },
)
);
const SCHEDULE_CARD_DAY_KEYS = [
'monday',
'tuesday',
'wednesday',
'thursday',
'friday',
'saturday',
'sunday',
]
"monday",
"tuesday",
"wednesday",
"thursday",
"friday",
"saturday",
"sunday",
];
const SCHEDULE_CARD_SLOT_KEYS: Record<TimeSlot, string> = {
morning: 'morning',
noon: 'noon',
night: 'evening',
}
morning: "morning",
noon: "noon",
night: "evening",
};
const SCHEDULE_CARDS: Record<string, string> = Object.fromEntries(
Object.entries(scheduleCardModules).map(([path, module]) => [
path.replace(/^.*\/|\.png$/g, ''),
path.replace(/^.*\/|\.png$/g, ""),
module.default,
]),
)
);
function scheduleCardImage (day: number, slot: TimeSlot): string {
const key = `${SCHEDULE_CARD_DAY_KEYS[day - 1]}-${SCHEDULE_CARD_SLOT_KEYS[slot]}`
return SCHEDULE_CARDS[key]
function scheduleCardImage(day: number, slot: TimeSlot): string {
const key = `${SCHEDULE_CARD_DAY_KEYS[day - 1]}-${SCHEDULE_CARD_SLOT_KEYS[slot]}`;
return SCHEDULE_CARDS[key];
}
const SLOTS: { key: TimeSlot, label: string, icon: string, headerClass: string }[] = [
{ key: 'morning', label: '早上', icon: 'mdi-weather-sunset-up', headerClass: 'bg-deep-orange' },
{ key: 'noon', label: '中午', icon: 'mdi-white-balance-sunny', headerClass: 'bg-amber-darken-2' },
{ key: 'night', label: '晚上', icon: 'mdi-weather-night', headerClass: 'bg-deep-purple' },
]
const SLOTS: {
key: TimeSlot;
label: string;
icon: string;
headerClass: string;
}[] = [
{
key: "morning",
label: "早上",
icon: "mdi-weather-sunset-up",
headerClass: "bg-deep-orange",
},
{
key: "noon",
label: "中午",
icon: "mdi-white-balance-sunny",
headerClass: "bg-amber-darken-2",
},
{
key: "night",
label: "晚上",
icon: "mdi-weather-night",
headerClass: "bg-deep-purple",
},
];
// 依目前選取的時段決定表格要顯示哪幾欄,順序固定為早上/中午/晚上
const visibleSlots = computed(() => SLOTS.filter(slot => store.selectedSlots.includes(slot.key)))
const visibleSlots = computed(() =>
SLOTS.filter((slot) => store.selectedSlots.includes(slot.key)),
);
// 今天對應的星期幾,換算成與 CourseRow.day 相同的規則(1 = 週一 … 7 = 週日)
const todayDay = computed(() => {
const jsDay = new Date().getDay()
return jsDay === 0 ? 7 : jsDay
})
const jsDay = new Date().getDay();
return jsDay === 0 ? 7 : jsDay;
});
// v-btn-toggle 以陣列表達勾選狀態,store 則以 Y/N 保存,兩者在此橋接
const selectedSlots = computed<TimeSlot[]>({
get: () => store.selectedSlots,
set: value => {
set: (value) => {
for (const slot of SLOTS) {
const next: YesNo = value.includes(slot.key) ? 'Y' : 'N'
store.setSlot(slot.key, next)
const next: YesNo = value.includes(slot.key) ? "Y" : "N";
store.setSlot(slot.key, next);
}
},
})
});
onMounted(() => {
store.loadAll()
})
store.loadAll();
});
</script>
<style scoped>
+55 -40
View File
@@ -7,12 +7,16 @@
<v-row class="mt-4" no-gutters>
<v-col cols="8">
<v-card-title> {{ store.userProfile.name }} 先生 </v-card-title>
<v-card-subtitle> 資料更新於 {{ todayTimeString }} </v-card-subtitle>
<v-card-subtitle>
資料更新於 {{ todayTimeString }}
</v-card-subtitle>
</v-col>
<v-col class="d-flex justify-center" cols="4">
<!-- 健康力分數大圓盤 -->
<div class="score-disk d-flex flex-column justify-center align-center">
<div
class="score-disk d-flex flex-column justify-center align-center"
>
<span class="score-val">{{ displayedHealthScore }}</span>
<span class="score-unit"></span>
</div>
@@ -56,7 +60,11 @@
<!-- 課程建議入口 -->
<v-list-item append-icon="mdi-chevron-right" class="bg-primary py-4" to="/course">
<v-list-item
append-icon="mdi-chevron-right"
class="bg-primary py-4"
to="/course"
>
<v-list-item-title class="font-weight-bold">
<v-icon icon="mdi-notebook-check-outline" size="16" />
課程建議
@@ -158,10 +166,17 @@
</v-btn>
<div class="d-flex align-center mt-4">
<v-icon class="mr-3" color="primary" icon="mdi-lock-outline" size="28" />
<v-icon
class="mr-3"
color="primary"
icon="mdi-lock-outline"
size="28"
/>
<div>
<div class="font-weight-bold text-grey-darken-4">登入後可使用完整功能</div>
<div class="font-weight-bold text-grey-darken-4">
登入後可使用完整功能
</div>
<div class="text-title-small text-grey">
普測預約現場報到量測紀錄與運動處方
@@ -184,70 +199,70 @@
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import BrandAvatar from '@/components/BrandAvatar.vue'
import HealthRadarChart from '@/components/HealthRadarChart.vue'
import Layout from '@/components/layout/DefaultLayout.vue'
import ModuleRouterCard from '@/components/ModuleRouterCard.vue'
import { useAppStore } from '@/stores/app'
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import BrandAvatar from "@/components/BrandAvatar.vue";
import HealthRadarChart from "@/components/HealthRadarChart.vue";
import Layout from "@/components/layout/DefaultLayout.vue";
import ModuleRouterCard from "@/components/ModuleRouterCard.vue";
import { useAppStore } from "@/stores/app";
const store = useAppStore()
const displayedHealthScore = ref(1)
let healthScoreTimer: number | undefined
const store = useAppStore();
const displayedHealthScore = ref(1);
let healthScoreTimer: number | undefined;
function animateHealthScore (targetScore: number) {
function animateHealthScore(targetScore: number) {
if (healthScoreTimer !== undefined) {
window.clearInterval(healthScoreTimer)
window.clearInterval(healthScoreTimer);
}
if (targetScore <= 1) {
displayedHealthScore.value = targetScore
return
displayedHealthScore.value = targetScore;
return;
}
displayedHealthScore.value = 1
displayedHealthScore.value = 1;
const duration = 900
const stepDuration = Math.max(12, Math.floor(duration / targetScore))
const duration = 900;
const stepDuration = Math.max(12, Math.floor(duration / targetScore));
healthScoreTimer = window.setInterval(() => {
if (displayedHealthScore.value >= targetScore) {
window.clearInterval(healthScoreTimer)
healthScoreTimer = undefined
return
window.clearInterval(healthScoreTimer);
healthScoreTimer = undefined;
return;
}
displayedHealthScore.value += 1
}, stepDuration)
displayedHealthScore.value += 1;
}, stepDuration);
}
onMounted(() => {
animateHealthScore(store.healthScore)
})
animateHealthScore(store.healthScore);
});
onBeforeUnmount(() => {
if (healthScoreTimer !== undefined) {
window.clearInterval(healthScoreTimer)
window.clearInterval(healthScoreTimer);
}
})
});
watch(
() => store.healthScore,
score => {
animateHealthScore(score)
(score) => {
animateHealthScore(score);
},
)
);
const todayTimeString = computed(() => {
const now = new Date()
const now = new Date();
const date = [
String(now.getMonth() + 1).padStart(2, '0'),
String(now.getDate()).padStart(2, '0'),
].join('/')
const time = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`
String(now.getMonth() + 1).padStart(2, "0"),
String(now.getDate()).padStart(2, "0"),
].join("/");
const time = `${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}`;
return `${date} ${time}`
})
return `${date} ${time}`;
});
</script>
<style scoped>
+15 -13
View File
@@ -1,40 +1,42 @@
<script setup lang="ts">
import coverImage from '@/assets/introduction/functional _fitness_all_ages/00_cover.jpg'
import overviewImage01 from '@/assets/introduction/functional _fitness_all_ages/01_overview.jpg'
import methodsImage from '@/assets/introduction/functional _fitness_all_ages/02_methods.jpg'
import resultsImage from '@/assets/introduction/functional _fitness_all_ages/03_results.jpg'
import overviewImage02 from '@/assets/introduction/functional _fitness_all_ages/04_overview.jpg'
import Layout from '@/components/layout/DefaultLayout.vue'
import coverImage from "@/assets/introduction/functional _fitness_all_ages/00_cover.jpg";
import overviewImage01 from "@/assets/introduction/functional _fitness_all_ages/01_overview.jpg";
import methodsImage from "@/assets/introduction/functional _fitness_all_ages/02_methods.jpg";
import resultsImage from "@/assets/introduction/functional _fitness_all_ages/03_results.jpg";
import overviewImage02 from "@/assets/introduction/functional _fitness_all_ages/04_overview.jpg";
import Layout from "@/components/layout/DefaultLayout.vue";
const slides = [
{
title: '封面',
title: "封面",
src: coverImage,
},
{
title: '研究總覽',
title: "研究總覽",
src: overviewImage01,
},
{
title: '方法與指標',
title: "方法與指標",
src: methodsImage,
},
{
title: '主要結果',
title: "主要結果",
src: resultsImage,
},
{
title: '研究總覽',
title: "研究總覽",
src: overviewImage02,
},
]
];
</script>
<template>
<Layout>
<template #main>
<v-card variant="flat">
<v-card-title class="font-weight-bold text-primary d-flex align-center py-0">
<v-card-title
class="font-weight-bold text-primary d-flex align-center py-0"
>
<v-icon class="mr-4" color="accent" icon="mdi-information-outline" />
計畫簡介
</v-card-title>
+53 -43
View File
@@ -7,8 +7,12 @@
<v-card class="bg-transparent" variant="flat">
<BrandAvatar />
<v-card-title class="text-primary font-weight-bold">運動玩轉健康力</v-card-title>
<v-card-subtitle class="text-primary">Taipei Masters Hub</v-card-subtitle>
<v-card-title class="text-primary font-weight-bold"
>運動玩轉健康力</v-card-title
>
<v-card-subtitle class="text-primary"
>Taipei Masters Hub</v-card-subtitle
>
</v-card>
<!-- 登入卡片 -->
@@ -85,7 +89,10 @@
<v-card-actions class="justify-center">
<span class="text-body-small text-grey">
登入即表示您同意本平台的
<a class="text-accent text-decoration-none font-weight-bold" href="#">
<a
class="text-accent text-decoration-none font-weight-bold"
href="#"
>
個資授權條款
</a>
</span>
@@ -103,83 +110,87 @@
</template>
<script setup lang="ts">
import { HTTPError } from 'ky'
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import BrandAvatar from '@/components/BrandAvatar.vue'
import { useAppStore } from '@/stores/app'
import { HTTPError } from "ky";
import { ref } from "vue";
import { useRouter } from "vue-router";
import BrandAvatar from "@/components/BrandAvatar.vue";
import { useAppStore } from "@/stores/app";
const store = useAppStore()
const router = useRouter()
const store = useAppStore();
const router = useRouter();
const account = ref('')
const loading = ref(false)
const lineLoading = ref(false)
const errorMessage = ref('')
const account = ref("");
const loading = ref(false);
const lineLoading = ref(false);
const errorMessage = ref("");
/** 後端要求純數字、09 開頭、共 10 碼;允許使用者輸入時夾雜連字號或空白 */
function normalizePhone (value: string) {
return value.replace(/\D/g, '')
function normalizePhone(value: string) {
return value.replace(/\D/g, "");
}
interface ErrorResponseBody {
/** 一般錯誤格式,例如 401 手機門號不存在 */
message?: string
message?: string;
/** 驗證失敗時(400)後端走 ASP.NET Core 的 ProblemDetails 格式 */
errors?: Record<string, string[]>
errors?: Record<string, string[]>;
}
/** 優先取用後端回應 body 的錯誤訊息,取不到時退回通用錯誤訊息 */
function resolveErrorMessage (error: unknown): string {
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 (
error instanceof HTTPError &&
error.data &&
typeof error.data === "object"
) {
const body = error.data as ErrorResponseBody;
if (body.message) {
return body.message
return body.message;
}
const firstFieldError = Object.values(body.errors ?? {})[0]?.[0]
const firstFieldError = Object.values(body.errors ?? {})[0]?.[0];
if (firstFieldError) {
return firstFieldError
return firstFieldError;
}
}
return error instanceof Error ? error.message : '登入失敗,請稍後再試。'
return error instanceof Error ? error.message : "登入失敗,請稍後再試。";
}
async function handleLogin () {
const phone = normalizePhone(account.value)
async function handleLogin() {
const phone = normalizePhone(account.value);
if (!/^09\d{8}$/.test(phone)) {
errorMessage.value = '請輸入正確的手機門號格式(09 開頭,共 10 碼)'
return
errorMessage.value = "請輸入正確的手機門號格式(09 開頭,共 10 碼)";
return;
}
errorMessage.value = ''
loading.value = true
errorMessage.value = "";
loading.value = true;
try {
await store.login(phone)
router.push('/')
await store.login(phone);
router.push("/");
} catch (error) {
errorMessage.value = resolveErrorMessage(error)
errorMessage.value = resolveErrorMessage(error);
} finally {
loading.value = false
loading.value = false;
}
}
async function _handleLineLogin () {
lineLoading.value = true
async function _handleLineLogin() {
lineLoading.value = true;
try {
await store.login('0910666888') // LINE 模擬登入預設手機
router.push('/')
await store.login("0910666888"); // LINE 模擬登入預設手機
router.push("/");
} catch (error) {
errorMessage.value = resolveErrorMessage(error)
errorMessage.value = resolveErrorMessage(error);
} finally {
lineLoading.value = false
lineLoading.value = false;
}
}
</script>
@@ -204,7 +215,7 @@ async function _handleLineLogin () {
rgba(255, 255, 255, 0.15) 65%,
transparent 100%
);
content: '';
content: "";
transform: rotate(18deg);
animation: glow-sweep 2.4s ease-in-out infinite;
pointer-events: none;
@@ -220,5 +231,4 @@ async function _handleLineLogin () {
left: 125%;
}
}
</style>
@@ -11,11 +11,20 @@
<!-- 進度指示器 -->
<v-row class="mb-6 justify-center">
<v-col v-for="(stepName, idx) in stepNames" :key="idx" class="text-center" cols="3">
<v-col
v-for="(stepName, idx) in stepNames"
:key="idx"
class="text-center"
cols="3"
>
<v-avatar
class="text-white font-weight-bold mb-1 elevation-1"
:color="
currentStep > idx ? 'primary' : currentStep === idx ? 'secondary' : 'grey-lighten-2'
currentStep > idx
? 'primary'
: currentStep === idx
? 'secondary'
: 'grey-lighten-2'
"
size="28"
>
@@ -43,8 +52,14 @@
<v-card-text class="py-2">
<!-- 步驟 1基本資料 -->
<div v-if="currentStep === 0">
<h2 class="text-title-medium font-weight-bold text-primary mb-4 d-flex align-center">
<v-icon class="mr-2" color="secondary" icon="mdi-account-circle" />
<h2
class="text-title-medium font-weight-bold text-primary mb-4 d-flex align-center"
>
<v-icon
class="mr-2"
color="secondary"
icon="mdi-account-circle"
/>
基本資料確認
</h2>
@@ -140,19 +155,31 @@
<!-- 步驟 2活動量與自評 -->
<div v-if="currentStep === 1">
<h2 class="text-title-medium font-weight-bold text-primary mb-4 d-flex align-center">
<h2
class="text-title-medium font-weight-bold text-primary mb-4 d-flex align-center"
>
<v-icon class="mr-2" color="secondary" icon="mdi-walk" />
活動習慣與想要改善的能力
</h2>
<p class="text-body-medium font-weight-bold text-grey-darken-2 mb-2">
<p
class="text-body-medium font-weight-bold text-grey-darken-2 mb-2"
>
1. 最近 7 您每週進行大運動幾次
</p>
<v-radio-group v-model="assessmentForm.exerciseFrequency" class="mb-4" color="primary">
<v-radio-group
v-model="assessmentForm.exerciseFrequency"
class="mb-4"
color="primary"
>
<v-card
class="mb-2 rounded-lg pa-1 cursor-pointer transition-card"
:color="assessmentForm.exerciseFrequency === '0' ? 'secondary' : 'grey-lighten-1'"
:color="
assessmentForm.exerciseFrequency === '0'
? 'secondary'
: 'grey-lighten-1'
"
variant="outlined"
@click="assessmentForm.exerciseFrequency = '0'"
>
@@ -161,7 +188,11 @@
<v-card
class="mb-2 rounded-lg pa-1 cursor-pointer transition-card"
:color="assessmentForm.exerciseFrequency === '1-2' ? 'secondary' : 'grey-lighten-1'"
:color="
assessmentForm.exerciseFrequency === '1-2'
? 'secondary'
: 'grey-lighten-1'
"
variant="outlined"
@click="assessmentForm.exerciseFrequency = '1-2'"
>
@@ -170,7 +201,11 @@
<v-card
class="rounded-lg pa-1 cursor-pointer transition-card"
:color="assessmentForm.exerciseFrequency === '3+' ? 'secondary' : 'grey-lighten-1'"
:color="
assessmentForm.exerciseFrequency === '3+'
? 'secondary'
: 'grey-lighten-1'
"
variant="outlined"
@click="assessmentForm.exerciseFrequency = '3+'"
>
@@ -178,7 +213,9 @@
</v-card>
</v-radio-group>
<p class="text-body-medium font-weight-bold text-grey-darken-2 mb-2">
<p
class="text-body-medium font-weight-bold text-grey-darken-2 mb-2"
>
2. 您目前最想改善的身體功能 (可複選)
</p>
@@ -186,16 +223,25 @@
<v-col v-for="imp in improvementsOptions" :key="imp" cols="6">
<v-card
class="rounded-lg py-2 px-1 text-center cursor-pointer transition-card"
:class="{ 'bg-teal-lighten-5': assessmentForm.wantedImprovements.includes(imp) }"
:class="{
'bg-teal-lighten-5':
assessmentForm.wantedImprovements.includes(imp),
}"
:color="
assessmentForm.wantedImprovements.includes(imp) ? 'primary' : 'grey-lighten-1'
assessmentForm.wantedImprovements.includes(imp)
? 'primary'
: 'grey-lighten-1'
"
variant="outlined"
@click="toggleImprovement(imp)"
>
<v-icon
class="mb-1"
:color="assessmentForm.wantedImprovements.includes(imp) ? 'primary' : 'grey'"
:color="
assessmentForm.wantedImprovements.includes(imp)
? 'primary'
: 'grey'
"
:icon="getImprovementIcon(imp)"
/>
@@ -207,21 +253,36 @@
<!-- 步驟 3身體狀況自評 -->
<div v-if="currentStep === 2">
<h2 class="text-title-medium font-weight-bold text-primary mb-4 d-flex align-center">
<v-icon class="mr-2" color="secondary" icon="mdi-alert-decagram" />
<h2
class="text-title-medium font-weight-bold text-primary mb-4 d-flex align-center"
>
<v-icon
class="mr-2"
color="secondary"
icon="mdi-alert-decagram"
/>
身體限制與慢性病史
</h2>
<p class="text-body-medium font-weight-bold text-grey-darken-2 mb-2">
<p
class="text-body-medium font-weight-bold text-grey-darken-2 mb-2"
>
1. 過去一年是否有過跌倒史
</p>
<v-radio-group v-model="assessmentForm.hasFalls" class="mb-4" color="primary" inline>
<v-radio-group
v-model="assessmentForm.hasFalls"
class="mb-4"
color="primary"
inline
>
<v-radio label="無跌倒" :value="false" />
<v-radio label="有跌倒過" :value="true" />
</v-radio-group>
<p class="text-body-medium font-weight-bold text-grey-darken-2 mb-2">
<p
class="text-body-medium font-weight-bold text-grey-darken-2 mb-2"
>
2. 目前身體關節疼痛狀況
</p>
@@ -234,7 +295,9 @@
variant="outlined"
/>
<p class="text-body-medium font-weight-bold text-grey-darken-2 mb-2">
<p
class="text-body-medium font-weight-bold text-grey-darken-2 mb-2"
>
3. 您有哪些慢性疾病 (可複選)
</p>
@@ -254,8 +317,16 @@
<!-- 步驟 4自評結果 -->
<div v-if="currentStep === 3" class="text-center py-4">
<v-avatar class="elevation-2 mb-4" :color="getResultColor(resultLevel)" size="72">
<v-icon color="white" :icon="getResultIcon(resultLevel)" size="40" />
<v-avatar
class="elevation-2 mb-4"
:color="getResultColor(resultLevel)"
size="72"
>
<v-icon
color="white"
:icon="getResultIcon(resultLevel)"
size="40"
/>
</v-avatar>
<h2
@@ -265,15 +336,26 @@
自評分級{{ resultLevel }}
</h2>
<p class="text-title-small text-grey-darken-2 mb-6">您的健康自評報告已產生</p>
<p class="text-title-small text-grey-darken-2 mb-6">
您的健康自評報告已產生
</p>
<v-card class="rounded-xl pa-4 text-left mb-6" color="grey-lighten-4" variant="flat">
<div class="font-weight-bold mb-2 text-primary d-flex align-center">
<v-card
class="rounded-xl pa-4 text-left mb-6"
color="grey-lighten-4"
variant="flat"
>
<div
class="font-weight-bold mb-2 text-primary d-flex align-center"
>
<v-icon class="mr-1" icon="mdi-text-box-search" />
系統評估建議
</div>
<p v-if="resultLevel === '低風險'" class="text-body-medium text-grey-darken-3 mb-2">
<p
v-if="resultLevel === '低風險'"
class="text-body-medium text-grey-darken-3 mb-2"
>
您的身體功能與日常活動習慣良好建議您每週繼續保持 3
次以上的運動並可以預約普測活動以獲得精準的五維健康指標與運動處方
</p>
@@ -289,7 +371,9 @@
檢測到您有多項慢性病或中重度疼痛運動時請特別留意建議您先向您的家庭醫師或社區據點諮詢並在運動普測現場合對您的健康狀態以確保運動安全
</p>
<div class="font-weight-bold text-body-medium text-secondary mt-3">
<div
class="font-weight-bold text-body-medium text-secondary mt-3"
>
* 禁忌提醒若收縮壓 >
160mmHg有關節強烈疼痛或暈眩時請立即暫停所有體能檢測與訓練
</div>
@@ -364,13 +448,13 @@
</template>
<script setup lang="ts">
import { computed, reactive, ref } from 'vue'
import Layout from '@/components/layout/DefaultLayout.vue'
import { useAppStore } from '@/stores/app'
import { computed, reactive, ref } from "vue";
import Layout from "@/components/layout/DefaultLayout.vue";
import { useAppStore } from "@/stores/app";
const store = useAppStore()
const currentStep = ref(0)
const stepNames = ['基本資料', '功能狀態', '健康限制', '自評結果']
const store = useAppStore();
const currentStep = ref(0);
const stepNames = ["基本資料", "功能狀態", "健康限制", "自評結果"];
const profileForm = reactive({
name: store.userProfile.name,
@@ -380,7 +464,7 @@ const profileForm = reactive({
height: store.userProfile.height,
weight: store.userProfile.weight,
consent: store.userProfile.consent,
})
});
const assessmentForm = reactive({
exerciseFrequency: store.selfAssessment.exerciseFrequency,
@@ -388,91 +472,105 @@ const assessmentForm = reactive({
hasFalls: store.selfAssessment.hasFalls,
painLevel: store.selfAssessment.painLevel,
chronicDiseases: [...store.selfAssessment.chronicDiseases],
})
});
const resultLevel = ref(store.selfAssessment.scoreLevel)
const resultLevel = ref(store.selfAssessment.scoreLevel);
const improvementsOptions = ['肌力', '平衡', '心肺', '柔軟度', '敏捷', '體脂/BMI']
const diseaseOptions = ['高血壓', '糖尿病', '高血脂', '心臟病', '關節退化', '骨質疏鬆']
const improvementsOptions = [
"肌力",
"平衡",
"心肺",
"柔軟度",
"敏捷",
"體脂/BMI",
];
const diseaseOptions = [
"高血壓",
"糖尿病",
"高血脂",
"心臟病",
"關節退化",
"骨質疏鬆",
];
const birthdayDate = computed<Date | null>({
get: () => parseDateString(profileForm.birthday),
set: value => {
profileForm.birthday = formatDateString(value)
set: (value) => {
profileForm.birthday = formatDateString(value);
},
})
});
function parseDateString (value: string) {
const [year, month, day] = value.split('-').map(Number)
function parseDateString(value: string) {
const [year, month, day] = value.split("-").map(Number);
if (!year || !month || !day) {
return null
return null;
}
return new Date(year, month - 1, day)
return new Date(year, month - 1, day);
}
function formatDateString (value: Date | null) {
function formatDateString(value: Date | null) {
if (!value) {
return ''
return "";
}
const year = value.getFullYear()
const month = String(value.getMonth() + 1).padStart(2, '0')
const day = String(value.getDate()).padStart(2, '0')
const year = value.getFullYear();
const month = String(value.getMonth() + 1).padStart(2, "0");
const day = String(value.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`
return `${year}-${month}-${day}`;
}
function getImprovementIcon (imp: string) {
function getImprovementIcon(imp: string) {
switch (imp) {
case '肌力': {
return 'mdi-arm-flex'
case "肌力": {
return "mdi-arm-flex";
}
case '平衡': {
return 'mdi-scale-balance'
case "平衡": {
return "mdi-scale-balance";
}
case '心肺': {
return 'mdi-heart-pulse'
case "心肺": {
return "mdi-heart-pulse";
}
case '柔軟度': {
return 'mdi-human-handsup'
case "柔軟度": {
return "mdi-human-handsup";
}
case '敏捷': {
return 'mdi-run-fast'
case "敏捷": {
return "mdi-run-fast";
}
case '體脂/BMI': {
return 'mdi-calculator'
case "體脂/BMI": {
return "mdi-calculator";
}
default: {
return 'mdi-help-circle'
return "mdi-help-circle";
}
}
}
function toggleImprovement (imp: string) {
const index = assessmentForm.wantedImprovements.indexOf(imp)
function toggleImprovement(imp: string) {
const index = assessmentForm.wantedImprovements.indexOf(imp);
if (index === -1) {
assessmentForm.wantedImprovements.push(imp)
assessmentForm.wantedImprovements.push(imp);
} else {
assessmentForm.wantedImprovements.splice(index, 1)
assessmentForm.wantedImprovements.splice(index, 1);
}
}
function scrollToPageTop () {
window.scrollTo({ top: 0 })
function scrollToPageTop() {
window.scrollTo({ top: 0 });
}
function goToPreviousStep () {
currentStep.value -= 1
scrollToPageTop()
function goToPreviousStep() {
currentStep.value -= 1;
scrollToPageTop();
}
function goToNextStep () {
currentStep.value += 1
scrollToPageTop()
function goToNextStep() {
currentStep.value += 1;
scrollToPageTop();
}
function handleSubmit () {
function handleSubmit() {
// 儲存至 store
store.updateProfile({
name: profileForm.name,
@@ -482,7 +580,7 @@ function handleSubmit () {
height: profileForm.height,
weight: profileForm.weight,
consent: profileForm.consent,
})
});
store.submitSelfAssessment({
exerciseFrequency: assessmentForm.exerciseFrequency,
@@ -490,24 +588,24 @@ function handleSubmit () {
hasFalls: assessmentForm.hasFalls,
painLevel: assessmentForm.painLevel,
chronicDiseases: assessmentForm.chronicDiseases,
})
});
// 取得計算後的結果
resultLevel.value = store.selfAssessment.scoreLevel
currentStep.value = 3
scrollToPageTop()
resultLevel.value = store.selfAssessment.scoreLevel;
currentStep.value = 3;
scrollToPageTop();
}
function getResultColor (lvl: string) {
if (lvl === '低風險') return 'success'
if (lvl === '需追蹤') return 'warning'
return 'error'
function getResultColor(lvl: string) {
if (lvl === "低風險") return "success";
if (lvl === "需追蹤") return "warning";
return "error";
}
function getResultIcon (lvl: string) {
if (lvl === '低風險') return 'mdi-check-decagram'
if (lvl === '需追蹤') return 'mdi-alert-decagram'
return 'mdi-alert-octagon'
function getResultIcon(lvl: string) {
if (lvl === "低風險") return "mdi-check-decagram";
if (lvl === "需追蹤") return "mdi-alert-decagram";
return "mdi-alert-octagon";
}
</script>
+7 -7
View File
@@ -1,21 +1,21 @@
import { createI18n } from 'vue-i18n'
import { createI18n } from "vue-i18n";
const messages = {
en: {
message: {
hello: 'hello world',
hello: "hello world",
},
},
ja: {
message: {
hello: 'こんにちは、世界',
hello: "こんにちは、世界",
},
},
}
};
export default createI18n({
legacy: false,
locale: 'en',
fallbackLocale: 'en',
locale: "en",
fallbackLocale: "en",
messages,
})
});
+10 -10
View File
@@ -5,18 +5,18 @@
*/
// Types
import type { App } from 'vue'
import type { App } from "vue";
import { createPinia } from 'pinia'
import { createPinia } from "pinia";
// Plugins
import router from '../router'
import i18n from './i18n'
import vuetify from './vuetify'
import router from "../router";
import i18n from "./i18n";
import vuetify from "./vuetify";
export function registerPlugins (app: App) {
app.use(vuetify)
app.use(createPinia())
app.use(i18n)
app.use(router)
export function registerPlugins(app: App) {
app.use(vuetify);
app.use(createPinia());
app.use(i18n);
app.use(router);
}
+21 -21
View File
@@ -5,43 +5,43 @@
*/
// Composables
import { createVuetify, type ThemeDefinition } from 'vuetify'
import { zhHant } from 'vuetify/locale'
import { createVuetify, type ThemeDefinition } from "vuetify";
import { zhHant } from "vuetify/locale";
// Styles
import '@mdi/font/css/materialdesignicons.css'
import 'vuetify/styles'
import "@mdi/font/css/materialdesignicons.css";
import "vuetify/styles";
const HPTheme: ThemeDefinition = {
dark: false,
colors: {
'background': '#F0F9F6', // 頁面背景
'surface': '#FFFFFF', // 卡片與白底區塊
'primary': '#1D7063', // 主要品牌色
'primary-darken-1': '#145248',
'secondary': '#dc5349', // CTA 按鈕
'secondary-darken-1': '#ab4c3d',
'accent': '#47C2A6', // 標籤與強調
'error': '#B00020',
'info': '#2196F3',
'success': '#4CAF50',
'warning': '#FB8C00',
background: "#F0F9F6", // 頁面背景
surface: "#FFFFFF", // 卡片與白底區塊
primary: "#1D7063", // 主要品牌色
"primary-darken-1": "#145248",
secondary: "#dc5349", // CTA 按鈕
"secondary-darken-1": "#ab4c3d",
accent: "#47C2A6", // 標籤與強調
error: "#B00020",
info: "#2196F3",
success: "#4CAF50",
warning: "#FB8C00",
},
}
};
// https://vuetifyjs.com/en/introduction/why-vuetify/#feature-guides
export default createVuetify({
locale: {
locale: 'zhHant',
fallback: 'zhHant',
locale: "zhHant",
fallback: "zhHant",
messages: { zhHant },
},
date: {
locale: {
zhHant: 'zh-TW',
zhHant: "zh-TW",
},
},
theme: {
defaultTheme: 'HPTheme',
defaultTheme: "HPTheme",
themes: { HPTheme },
},
})
});
+40 -39
View File
@@ -1,79 +1,80 @@
import { createRouter, createWebHistory } from 'vue-router'
import Appointment from '@/pages/appointment.vue'
import Checkin from '@/pages/checkin.vue'
import CheckinDetail from '@/pages/checkinDetail.vue'
import Course from '@/pages/course.vue'
import Index from '@/pages/index.vue'
import Introduction from '@/pages/introduction.vue'
import Login from '@/pages/login.vue'
import SelfAssessment from '@/pages/self-assessment.vue'
import { useAppStore } from '@/stores/app'
import { createRouter, createWebHistory } from "vue-router";
import Appointment from "@/pages/appointment.vue";
import Checkin from "@/pages/checkin.vue";
import CheckinDetail from "@/pages/checkinDetail.vue";
import Course from "@/pages/course.vue";
import Index from "@/pages/index.vue";
import Introduction from "@/pages/introduction.vue";
import Login from "@/pages/login.vue";
import SelfAssessment from "@/pages/self-assessment.vue";
import { useAppStore } from "@/stores/app";
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
scrollBehavior () {
return { left: 0, top: 0 }
scrollBehavior() {
return { left: 0, top: 0 };
},
routes: [
{
path: '/',
name: 'home',
path: "/",
name: "home",
component: Index,
},
{
path: '/introduction',
name: 'introduction',
path: "/introduction",
name: "introduction",
component: Introduction,
meta: { title: '計畫簡介|運動玩轉健康力' },
meta: { title: "計畫簡介|運動玩轉健康力" },
},
{
path: '/login',
name: 'login',
path: "/login",
name: "login",
component: Login,
},
{
path: '/self-assessment',
name: 'self-assessment',
path: "/self-assessment",
name: "self-assessment",
component: SelfAssessment,
},
{
path: '/appointment',
name: 'appointment',
path: "/appointment",
name: "appointment",
component: Appointment,
meta: { requiresAuth: true },
},
{
path: '/course',
name: 'course',
path: "/course",
name: "course",
component: Course,
meta: { requiresAuth: true, title: '課程建議|運動玩轉健康力' },
meta: { requiresAuth: true, title: "課程建議|運動玩轉健康力" },
},
{
path: '/checkin',
name: 'checkin',
path: "/checkin",
name: "checkin",
component: Checkin,
meta: { requiresAuth: true },
},
{
path: '/checkin/:code',
name: 'checkin-detail',
path: "/checkin/:code",
name: "checkin-detail",
component: CheckinDetail,
meta: { requiresAuth: true },
},
],
})
});
// 簡單的導航守衛:只有標記需要登入的頁面會導向登入頁
router.beforeEach(to => {
const store = useAppStore()
router.beforeEach((to) => {
const store = useAppStore();
if (to.meta.requiresAuth && !store.isLoggedIn) {
return { name: 'login' }
return { name: "login" };
}
})
});
router.afterEach(to => {
document.title = typeof to.meta.title === 'string' ? to.meta.title : '運動玩轉健康力'
})
router.afterEach((to) => {
document.title =
typeof to.meta.title === "string" ? to.meta.title : "運動玩轉健康力";
});
export default router
export default router;
+212 -198
View File
@@ -1,365 +1,377 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { login as apiLogin } from '@/api/auth'
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'
const USE_MOCK = import.meta.env.VITE_USE_MOCK !== "false";
export interface UserProfile {
name: string
birthday: string
gender: string
height: number
weight: number
phone: string
email: string
emergencyContact: string
emergencyPhone: string
consent: boolean
name: string;
birthday: string;
gender: string;
height: number;
weight: number;
phone: string;
email: string;
emergencyContact: string;
emergencyPhone: string;
consent: boolean;
}
export interface SelfAssessment {
exerciseFrequency: string
painLevel: string
hasFalls: boolean
chronicDiseases: string[]
wantedImprovements: string[]
scoreLevel: string
completed: boolean
exerciseFrequency: string;
painLevel: string;
hasFalls: boolean;
chronicDiseases: string[];
wantedImprovements: string[];
scoreLevel: string;
completed: boolean;
}
export interface Appointment {
id: string
title: string
date: string
time: string
location: string
status: '未報名' | '已報名' | '已報到'
slots: number
organizer: string
items: string[]
id: string;
title: string;
date: string;
time: string;
location: string;
status: "未報名" | "已報名" | "已報到";
slots: number;
organizer: string;
items: string[];
}
export interface MeasurementStation {
name: string
completed: boolean
time?: string
values: Record<string, any>
name: string;
completed: boolean;
time?: string;
values: Record<string, any>;
}
function createDefaultUserProfile (): UserProfile {
function createDefaultUserProfile(): UserProfile {
return {
name: '林大明',
birthday: '1961-08-15',
gender: '男',
name: "林大明",
birthday: "1961-08-15",
gender: "男",
height: 168,
weight: 65,
phone: '0912-345-678',
email: 'jianguo.lin@email.com',
emergencyContact: '林小明 (長子)',
emergencyPhone: '0987-654-321',
phone: "0912-345-678",
email: "jianguo.lin@email.com",
emergencyContact: "林小明 (長子)",
emergencyPhone: "0987-654-321",
consent: true,
}
};
}
function createDefaultSelfAssessment (): SelfAssessment {
function createDefaultSelfAssessment(): SelfAssessment {
return {
exerciseFrequency: '1-2',
painLevel: '輕微',
exerciseFrequency: "1-2",
painLevel: "輕微",
hasFalls: false,
chronicDiseases: ['高血壓'],
wantedImprovements: ['肌力', '平衡'],
scoreLevel: '需追蹤',
chronicDiseases: ["高血壓"],
wantedImprovements: ["肌力", "平衡"],
scoreLevel: "需追蹤",
completed: true,
}
};
}
function createDefaultAppointments (): Appointment[] {
function createDefaultAppointments(): Appointment[] {
const measurementItems = [
'血壓',
'體脂',
'握力',
'5次坐站',
'30秒椅子坐立',
'原地抬膝踏步',
'手臂屈舉',
'抓背測驗',
'坐姿體前彎',
'2.44米繞物',
'開眼單足立',
]
const location = '信義區運動中心 3 樓多功能教室'
const organizer = '臺北市政府體育局'
"血壓",
"體脂",
"握力",
"5次坐站",
"30秒椅子坐立",
"原地抬膝踏步",
"手臂屈舉",
"抓背測驗",
"坐姿體前彎",
"2.44米繞物",
"開眼單足立",
];
const location = "信義區運動中心 3 樓多功能教室";
const organizer = "臺北市政府體育局";
return [
{
id: 'apt-01',
title: '銀髮活力公益普測 A 時段',
date: '2026-07-04',
time: '09:00 - 10:00',
id: "apt-01",
title: "銀髮活力公益普測 A 時段",
date: "2026-07-04",
time: "09:00 - 10:00",
location,
status: '已報名',
status: "已報名",
slots: 12,
organizer,
items: measurementItems,
},
{
id: 'apt-02',
title: '銀髮活力公益普測 B 時段',
date: '2026-07-04',
time: '10:30 - 11:30',
id: "apt-02",
title: "銀髮活力公益普測 B 時段",
date: "2026-07-04",
time: "10:30 - 11:30",
location,
status: '已報到',
status: "已報到",
slots: 5,
organizer,
items: measurementItems,
},
{
id: 'apt-03',
title: '銀髮活力公益普測 C 時段',
date: '2026-07-04',
time: '13:30 - 14:30',
id: "apt-03",
title: "銀髮活力公益普測 C 時段",
date: "2026-07-04",
time: "13:30 - 14:30",
location,
status: '未報名',
status: "未報名",
slots: 45,
organizer,
items: measurementItems,
},
]
];
}
function createDefaultMeasurements (): Record<string, MeasurementStation> {
function createDefaultMeasurements(): Record<string, MeasurementStation> {
return {
BloodPressure: {
name: '血壓量測',
name: "血壓量測",
completed: true,
time: '2026-06-18 15:00',
time: "2026-06-18 15:00",
values: { systolic: 132, diastolic: 84, heartRate: 72 },
},
BodyFat: {
name: '體組成分析',
name: "體組成分析",
completed: true,
time: '2026-06-18 15:05',
time: "2026-06-18 15:05",
values: { bmi: 23, bodyFatPct: 24.5 },
},
GripStrength: {
name: '左右握力',
name: "左右握力",
completed: false,
values: { leftHand: 0, rightHand: 0 },
},
Sit5x: {
name: '5次坐站測試',
name: "5次坐站測試",
completed: false,
values: { durationSeconds: 0 },
},
Sit30s: {
name: '30秒椅子坐立',
name: "30秒椅子坐立",
completed: false,
values: { count: 0 },
},
StepTest: {
name: '2分鐘原地抬膝踏步',
name: "2分鐘原地抬膝踏步",
completed: false,
values: { count: 0 },
},
ArmTest: {
name: '30秒肱二頭肌手臂屈舉',
name: "30秒肱二頭肌手臂屈舉",
completed: false,
values: { count: 0 },
},
BackTest: {
name: '抓背測驗(柔軟度)',
name: "抓背測驗(柔軟度)",
completed: false,
values: { leftDistanceCm: 0, rightDistanceCm: 0 },
},
SitReachTest: {
name: '坐姿體前彎(柔軟度)',
name: "坐姿體前彎(柔軟度)",
completed: false,
values: { distanceCm: 0 },
},
UpGo8ft: {
name: '2.44公尺起立繞物(敏捷)',
name: "2.44公尺起立繞物(敏捷)",
completed: false,
values: { durationSeconds: 0 },
},
OneLegTest: {
name: '開眼單足立(平衡)',
name: "開眼單足立(平衡)",
completed: false,
values: { leftSeconds: 0, rightSeconds: 0 },
},
}
};
}
function createDefaultRadarData () {
function createDefaultRadarData() {
return {
strength: 65,
balance: 60,
flexibility: 55,
agility: 70,
cardio: 62,
}
};
}
export const useAppStore = defineStore('app', () => {
const isLoggedIn = ref(false)
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())
const measurements = ref<Record<string, MeasurementStation>>(createDefaultMeasurements())
const healthScore = ref(72)
const radarData = ref(createDefaultRadarData())
const isReportGenerated = ref(false)
const userID = ref("");
const userProfile = ref<UserProfile>(createDefaultUserProfile());
const selfAssessment = ref<SelfAssessment>(createDefaultSelfAssessment());
const appointments = ref<Appointment[]>(createDefaultAppointments());
const measurements = ref<Record<string, MeasurementStation>>(
createDefaultMeasurements(),
);
const healthScore = ref(72);
const radarData = ref(createDefaultRadarData());
const isReportGenerated = ref(false);
const completedStationsCount = computed(() => {
return Object.values(measurements.value).filter(station => station.completed).length
})
return Object.values(measurements.value).filter(
(station) => station.completed,
).length;
});
const totalStationsCount = computed(() => {
return Object.keys(measurements.value).length
})
return Object.keys(measurements.value).length;
});
const progressPercent = computed((): number => {
const keys = Object.keys(measurements.value)
const keys = Object.keys(measurements.value);
if (keys.length === 0) {
return 0
return 0;
}
const done = Object.values(measurements.value).filter(station => station.completed).length
return Math.round((done / keys.length) * 100)
})
const done = Object.values(measurements.value).filter(
(station) => station.completed,
).length;
return Math.round((done / keys.length) * 100);
});
/** 手機門號登入;後端未完成時走模擬資料,完成後設 VITE_USE_MOCK=false 改打真實 API */
async function login (phone: string) {
async function login(phone: string) {
if (USE_MOCK) {
isLoggedIn.value = true
userID.value = 'MOCK-USER'
userProfile.value.phone = phone
return
isLoggedIn.value = true;
userID.value = "MOCK-USER";
userProfile.value.phone = phone;
return;
}
const response = await apiLogin(phone)
const response = await apiLogin(phone);
isLoggedIn.value = true
userID.value = response.userID
userProfile.value.phone = response.phone
userProfile.value.name = response.userName
isLoggedIn.value = true;
userID.value = response.userID;
userProfile.value.phone = response.phone;
userProfile.value.name = response.userName;
}
function logout () {
isLoggedIn.value = false
userID.value = ''
function logout() {
isLoggedIn.value = false;
userID.value = "";
}
function updateProfile (profile: Partial<UserProfile>) {
userProfile.value = { ...userProfile.value, ...profile }
function updateProfile(profile: Partial<UserProfile>) {
userProfile.value = { ...userProfile.value, ...profile };
}
function submitSelfAssessment (answers: Partial<SelfAssessment>) {
function submitSelfAssessment(answers: Partial<SelfAssessment>) {
selfAssessment.value = {
...selfAssessment.value,
...answers,
completed: true,
}
};
let level = '低風險'
if (answers.exerciseFrequency === '0' || answers.hasFalls === true) {
level = '需追蹤'
let level = "低風險";
if (answers.exerciseFrequency === "0" || answers.hasFalls === true) {
level = "需追蹤";
}
if (
answers.painLevel === '中度'
|| answers.painLevel === '重度'
|| (answers.chronicDiseases && answers.chronicDiseases.length >= 2)
answers.painLevel === "中度" ||
answers.painLevel === "重度" ||
(answers.chronicDiseases && answers.chronicDiseases.length >= 2)
) {
level = '建議諮詢'
level = "建議諮詢";
}
selfAssessment.value.scoreLevel = level
selfAssessment.value.scoreLevel = level;
}
function registerAppointment (appointmentId: string) {
const appointment = appointments.value.find(item => item.id === appointmentId)
function registerAppointment(appointmentId: string) {
const appointment = appointments.value.find(
(item) => item.id === appointmentId,
);
if (!appointment) {
return
return;
}
appointment.status = '已報名'
appointment.slots -= 1
appointment.status = "已報名";
appointment.slots -= 1;
}
function cancelAppointment (appointmentId: string) {
const appointment = appointments.value.find(item => item.id === appointmentId)
function cancelAppointment(appointmentId: string) {
const appointment = appointments.value.find(
(item) => item.id === appointmentId,
);
if (!appointment) {
return
return;
}
appointment.status = '未報名'
appointment.slots += 1
appointment.status = "未報名";
appointment.slots += 1;
}
function checkinAppointment (appointmentId: string) {
const appointment = appointments.value.find(item => item.id === appointmentId)
function checkinAppointment(appointmentId: string) {
const appointment = appointments.value.find(
(item) => item.id === appointmentId,
);
if (!appointment) {
return
return;
}
appointment.status = '已報到'
appointment.status = "已報到";
}
function saveMeasurement (code: string, values: Record<string, any>) {
const measurement = measurements.value[code]
function saveMeasurement(code: string, values: Record<string, any>) {
const measurement = measurements.value[code];
if (!measurement) {
return
return;
}
const now = new Date()
const timeStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')} ${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`
const now = new Date();
const timeStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")} ${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}`;
measurement.values = { ...values }
measurement.completed = true
measurement.time = timeStr
measurement.values = { ...values };
measurement.completed = true;
measurement.time = timeStr;
}
function generateFinalReport () {
const currentMeasurements = measurements.value
function generateFinalReport() {
const currentMeasurements = measurements.value;
let strScore = 65
let strScore = 65;
if (currentMeasurements.GripStrength.completed) {
const avgGrip
= ((currentMeasurements.GripStrength.values.leftHand || 0)
+ (currentMeasurements.GripStrength.values.rightHand || 0))
/ 2
const limit = userProfile.value.gender === '男' ? 30 : 20
strScore = Math.min(95, Math.max(50, Math.round((avgGrip / limit) * 75)))
const avgGrip =
((currentMeasurements.GripStrength.values.leftHand || 0) +
(currentMeasurements.GripStrength.values.rightHand || 0)) /
2;
const limit = userProfile.value.gender === "男" ? 30 : 20;
strScore = Math.min(95, Math.max(50, Math.round((avgGrip / limit) * 75)));
}
let balScore = 60
let balScore = 60;
if (currentMeasurements.OneLegTest.completed) {
const maxSec = Math.max(
currentMeasurements.OneLegTest.values.leftSeconds || 0,
currentMeasurements.OneLegTest.values.rightSeconds || 0,
)
balScore = Math.min(98, Math.max(40, Math.round((maxSec / 30) * 90)))
);
balScore = Math.min(98, Math.max(40, Math.round((maxSec / 30) * 90)));
}
let flexScore = 55
let flexScore = 55;
if (currentMeasurements.SitReachTest.completed) {
const dist = currentMeasurements.SitReachTest.values.distanceCm || 0
flexScore = Math.min(95, Math.max(45, 60 + dist * 1.5))
const dist = currentMeasurements.SitReachTest.values.distanceCm || 0;
flexScore = Math.min(95, Math.max(45, 60 + dist * 1.5));
}
let agiScore = 70
let agiScore = 70;
if (currentMeasurements.UpGo8ft.completed) {
const sec = currentMeasurements.UpGo8ft.values.durationSeconds || 10
agiScore = Math.min(95, Math.max(40, Math.round(100 - sec * 5)))
const sec = currentMeasurements.UpGo8ft.values.durationSeconds || 10;
agiScore = Math.min(95, Math.max(40, Math.round(100 - sec * 5)));
}
let cardScore = 62
let cardScore = 62;
if (currentMeasurements.StepTest.completed) {
const count = currentMeasurements.StepTest.values.count || 60
cardScore = Math.min(95, Math.max(45, Math.round((count / 80) * 80)))
const count = currentMeasurements.StepTest.values.count || 60;
cardScore = Math.min(95, Math.max(45, Math.round((count / 80) * 80)));
}
radarData.value = {
@@ -368,27 +380,29 @@ export const useAppStore = defineStore('app', () => {
flexibility: flexScore,
agility: agiScore,
cardio: cardScore,
}
};
const avg = Math.round((strScore + balScore + flexScore + agiScore + cardScore) / 5)
healthScore.value = Math.min(100, avg + 5)
isReportGenerated.value = true
const avg = Math.round(
(strScore + balScore + flexScore + agiScore + cardScore) / 5,
);
healthScore.value = Math.min(100, avg + 5);
isReportGenerated.value = true;
}
function resetMeasurements () {
function resetMeasurements() {
for (const [key, measurement] of Object.entries(measurements.value)) {
if (key !== 'BloodPressure' && key !== 'BodyFat') {
measurement.completed = false
if (key !== "BloodPressure" && key !== "BodyFat") {
measurement.completed = false;
for (const valueKey of Object.keys(measurement.values)) {
measurement.values[valueKey] = 0
measurement.values[valueKey] = 0;
}
}
}
isReportGenerated.value = false
healthScore.value = 72
radarData.value = createDefaultRadarData()
isReportGenerated.value = false;
healthScore.value = 72;
radarData.value = createDefaultRadarData();
}
return {
@@ -414,5 +428,5 @@ export const useAppStore = defineStore('app', () => {
saveMeasurement,
generateFinalReport,
resetMeasurements,
}
})
};
});
+225 -119
View File
@@ -5,225 +5,331 @@ import type {
FitnessGroup,
TimeSlot,
YesNo,
} from '@/api/types'
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
} from "@/api/types";
import { defineStore } from "pinia";
import { computed, ref } from "vue";
import {
fetchCourseList,
fetchGroup,
fetchMorning,
fetchNight,
fetchNoon,
} from '@/api/course'
import { useAppStore } from '@/stores/app'
} from "@/api/course";
import { useAppStore } from "@/stores/app";
/** 後端尚未完成,預設走前端模擬資料;設定 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";
/** 模擬網路延遲,讓載入狀態在開發時可被觀察到 */
const MOCK_DELAY = 300
const MOCK_DELAY = 300;
export interface GroupMeta {
label: string
color: string
label: string;
color: string;
/** 以活動強度遞增的圖示表達組別高低,讓組別不只靠顏色辨識 */
icon: string
description: string
icon: string;
description: string;
}
/** 組別的顯示資訊,文案取自 doc/課程說明.md */
export const GROUP_META: Record<FitnessGroup, GroupMeta> = {
low: {
label: '低體適能',
color: 'warning',
icon: 'mdi-walk',
description: '肌力、平衡與心肺功能較弱,以降低跌倒風險、重建日常生活功能為優先。',
label: "低體適能",
color: "warning",
icon: "mdi-walk",
description:
"肌力、平衡與心肺功能較弱,以降低跌倒風險、重建日常生活功能為優先。",
},
medium: {
label: '中體適能',
color: 'info',
icon: 'mdi-run',
description: '尚未失能但已出現部分衰退,重點在累積功能儲備、增加肌肉量與心肺能力。',
label: "中體適能",
color: "info",
icon: "mdi-run",
description:
"尚未失能但已出現部分衰退,重點在累積功能儲備、增加肌肉量與心肺能力。",
},
high: {
label: '高體適能',
color: 'success',
icon: 'mdi-run-fast',
description: '功能儲備高、跌倒風險低,目標是延緩衰退速度並長期維持既有功能。',
label: "高體適能",
color: "success",
icon: "mdi-run-fast",
description:
"功能儲備高、跌倒風險低,目標是延緩衰退速度並長期維持既有功能。",
},
}
};
/**
* 三組一週課表,內容逐格轉錄自 doc/課程表.png。
* 後端完成後這份資料會由第 5 支 API 提供。
*/
function createCourseTable (): Record<FitnessGroup, CourseRow[]> {
function createCourseTable(): Record<FitnessGroup, CourseRow[]> {
return {
low: [
{ day: 1, morning: '慢走 15 分鐘', noon: '正常飲食,多吃蔬菜與蛋白質', night: '坐站訓練 10 次 ×2 組+伸展 10 分鐘' },
{ day: 2, morning: '公園散步 20 分鐘', noon: '1 顆蛋+豆漿', night: '單腳站立和平衡訓練 15 分鐘' },
{ day: 3, morning: '慢走 15 分鐘', noon: '魚肉與青菜', night: '扶牆踮腳 15 次 ×2 組' },
{ day: 4, morning: '休閒散步 20 分鐘', noon: '補充水果', night: '伸展運動 10 分鐘' },
{ day: 5, morning: '慢走 20 分鐘', noon: '雞肉與蔬菜', night: '坐站訓練+平衡訓練' },
{ day: 6, morning: '腳踏車或散步 30 分鐘', noon: '均衡飲食', night: '全身伸展' },
{ day: 7, morning: '輕鬆散步', noon: '與家人共餐', night: '早睡休息' },
{
day: 1,
morning: "慢走 15 分鐘",
noon: "正常飲食,多吃蔬菜與蛋白質",
night: "坐站訓練 10 次 ×2 組+伸展 10 分鐘",
},
{
day: 2,
morning: "公園散步 20 分鐘",
noon: "1 顆蛋+豆漿",
night: "單腳站立和平衡訓練 15 分鐘",
},
{
day: 3,
morning: "慢走 15 分鐘",
noon: "魚肉與青菜",
night: "扶牆踮腳 15 次 ×2 組",
},
{
day: 4,
morning: "休閒散步 20 分鐘",
noon: "補充水果",
night: "伸展運動 10 分鐘",
},
{
day: 5,
morning: "慢走 20 分鐘",
noon: "雞肉與蔬菜",
night: "坐站訓練+平衡訓練",
},
{
day: 6,
morning: "腳踏車或散步 30 分鐘",
noon: "均衡飲食",
night: "全身伸展",
},
{ day: 7, morning: "輕鬆散步", noon: "與家人共餐", night: "早睡休息" },
],
medium: [
{ day: 1, morning: '快走 30 分鐘', noon: '高蛋白午餐', night: '深蹲 15 次 ×3 組' },
{ day: 2, morning: '騎腳踏車 40 分鐘', noon: '水果與豆類', night: '伸展 15 分鐘' },
{ day: 3, morning: '快走 30 分鐘', noon: '魚肉與蔬菜', night: '彈力帶訓練' },
{ day: 4, morning: '游泳或太極 40 分鐘', noon: '均衡飲食', night: '核心訓練 15 分鐘' },
{ day: 5, morning: '快走 30 分鐘', noon: '高蛋白午餐', night: '深蹲+彈力帶' },
{ day: 6, morning: '郊山健行 60 分鐘', noon: '補充水分', night: '輕鬆散步' },
{ day: 7, morning: '休息', noon: '家人聚餐', night: '伸展 10 分鐘' },
{
day: 1,
morning: "快走 30 分鐘",
noon: "高蛋白午餐",
night: "深蹲 15 次 ×3 組",
},
{
day: 2,
morning: "騎腳踏車 40 分鐘",
noon: "水果與豆類",
night: "伸展 15 分鐘",
},
{
day: 3,
morning: "快走 30 分鐘",
noon: "魚肉與蔬菜",
night: "彈力帶訓練",
},
{
day: 4,
morning: "游泳或太極 40 分鐘",
noon: "均衡飲食",
night: "核心訓練 15 分鐘",
},
{
day: 5,
morning: "快走 30 分鐘",
noon: "高蛋白午餐",
night: "深蹲+彈力帶",
},
{
day: 6,
morning: "郊山健行 60 分鐘",
noon: "補充水分",
night: "輕鬆散步",
},
{ day: 7, morning: "休息", noon: "家人聚餐", night: "伸展 10 分鐘" },
],
high: [
{ day: 1, morning: '重量訓練 60 分鐘', noon: '蛋白質與蔬菜', night: '散步 20 分鐘' },
{ day: 2, morning: '快走或慢跑 45 分鐘', noon: '水果與堅果', night: '核心訓練 20 分鐘' },
{ day: 3, morning: '平衡與敏捷訓練 40 分鐘', noon: '均衡飲食', night: '伸展 15 分鐘' },
{ day: 4, morning: '重量訓練 60 分鐘', noon: '高蛋白午餐', night: '散步 20 分鐘' },
{ day: 5, morning: '間歇跑 30 分鐘', noon: '補充水分', night: '輕量伸展' },
{ day: 6, morning: '登山、羽球或游泳 90 分鐘', noon: '魚類與蔬菜', night: '放鬆休息' },
{ day: 7, morning: '輕鬆散步 30 分鐘', noon: '家人聚餐', night: '早睡恢復' },
{
day: 1,
morning: "重量訓練 60 分鐘",
noon: "蛋白質與蔬菜",
night: "散步 20 分鐘",
},
{
day: 2,
morning: "快走或慢跑 45 分鐘",
noon: "水果與堅果",
night: "核心訓練 20 分鐘",
},
{
day: 3,
morning: "平衡與敏捷訓練 40 分鐘",
noon: "均衡飲食",
night: "伸展 15 分鐘",
},
{
day: 4,
morning: "重量訓練 60 分鐘",
noon: "高蛋白午餐",
night: "散步 20 分鐘",
},
{
day: 5,
morning: "間歇跑 30 分鐘",
noon: "補充水分",
night: "輕量伸展",
},
{
day: 6,
morning: "登山、羽球或游泳 90 分鐘",
noon: "魚類與蔬菜",
night: "放鬆休息",
},
{
day: 7,
morning: "輕鬆散步 30 分鐘",
noon: "家人聚餐",
night: "早睡恢復",
},
],
}
};
}
function delay (ms: number) {
return new Promise(resolve => setTimeout(resolve, ms))
function delay(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/** 模擬後端依 Y/N 篩選時段欄位的行為 */
function mockCourseList (params: CourseListParams): CourseList {
return createCourseTable()[params.group].map(row => {
const filtered: CourseRow = { day: row.day }
function mockCourseList(params: CourseListParams): CourseList {
return createCourseTable()[params.group].map((row) => {
const filtered: CourseRow = { day: row.day };
if (params.morning === 'Y') {
filtered.morning = row.morning
if (params.morning === "Y") {
filtered.morning = row.morning;
}
if (params.noon === 'Y') {
filtered.noon = row.noon
if (params.noon === "Y") {
filtered.noon = row.noon;
}
if (params.night === 'Y') {
filtered.night = row.night
if (params.night === "Y") {
filtered.night = row.night;
}
return filtered
})
return filtered;
});
}
export const useCourseStore = defineStore('course', () => {
const group = ref<FitnessGroup>('medium')
const morning = ref<YesNo>('Y')
const noon = ref<YesNo>('Y')
const night = ref<YesNo>('Y')
const courseList = ref<CourseList>([])
const loading = ref(false)
const error = ref('')
export const useCourseStore = defineStore("course", () => {
const group = ref<FitnessGroup>("medium");
const morning = ref<YesNo>("Y");
const noon = ref<YesNo>("Y");
const night = ref<YesNo>("Y");
const courseList = ref<CourseList>([]);
const loading = ref(false);
const error = ref("");
const groupMeta = computed(() => GROUP_META[group.value])
const groupMeta = computed(() => GROUP_META[group.value]);
/** 目前被選取的時段,供畫面判斷要顯示哪些欄位 */
const selectedSlots = computed<TimeSlot[]>(() => {
const slots: TimeSlot[] = []
const slots: TimeSlot[] = [];
if (morning.value === 'Y') {
slots.push('morning')
if (morning.value === "Y") {
slots.push("morning");
}
if (noon.value === 'Y') {
slots.push('noon')
if (noon.value === "Y") {
slots.push("noon");
}
if (night.value === 'Y') {
slots.push('night')
if (night.value === "Y") {
slots.push("night");
}
return slots
})
return slots;
});
const hasCourse = computed(() => selectedSlots.value.length > 0 && courseList.value.length > 0)
const hasCourse = computed(
() => selectedSlots.value.length > 0 && courseList.value.length > 0,
);
/** 前 4 支 API:平行取得組別與三個時段設定 */
async function loadPreferences () {
async function loadPreferences() {
if (USE_MOCK) {
await delay(MOCK_DELAY)
group.value = 'medium'
morning.value = 'Y'
noon.value = 'Y'
night.value = 'Y'
return
await delay(MOCK_DELAY);
group.value = "medium";
morning.value = "Y";
noon.value = "Y";
night.value = "Y";
return;
}
// 真實後端:course/group 需要 userIDcourse/morning、noon、night 需要 group
// 兩者有相依順序,因此無法四支平行呼叫(與 doc/課程建議API規格.md 的示意圖不同,
// 以 doc/課程建議_API_前端使用說明_20260806.md 的建議測試流程為準)
const { userID } = useAppStore()
const groupValue = await fetchGroup(userID)
const { userID } = useAppStore();
const groupValue = await fetchGroup(userID);
const [morningValue, noonValue, nightValue] = await Promise.all([
fetchMorning(groupValue),
fetchNoon(groupValue),
fetchNight(groupValue),
])
]);
group.value = groupValue
morning.value = morningValue
noon.value = noonValue
night.value = nightValue
group.value = groupValue;
morning.value = morningValue;
noon.value = noonValue;
night.value = nightValue;
}
/** 第 5 支 API:以前 4 個值為參數取得課表 */
async function loadCourseList () {
async function loadCourseList() {
const params: CourseListParams = {
group: group.value,
morning: morning.value,
noon: noon.value,
night: night.value,
}
};
if (USE_MOCK) {
await delay(MOCK_DELAY)
courseList.value = mockCourseList(params)
return
await delay(MOCK_DELAY);
courseList.value = mockCourseList(params);
return;
}
courseList.value = await fetchCourseList(params)
courseList.value = await fetchCourseList(params);
}
/** 頁面進入時呼叫:先取設定再取課表 */
async function loadAll () {
loading.value = true
error.value = ''
async function loadAll() {
loading.value = true;
error.value = "";
try {
await loadPreferences()
await loadCourseList()
await loadPreferences();
await loadCourseList();
} catch (error_) {
error.value = error_ instanceof Error ? error_.message : '課程資料載入失敗,請稍後再試。'
courseList.value = []
error.value =
error_ instanceof Error
? error_.message
: "課程資料載入失敗,請稍後再試。";
courseList.value = [];
} finally {
loading.value = false
loading.value = false;
}
}
/** 使用者切換時段後重新取課表 */
async function setSlot (slot: TimeSlot, value: YesNo) {
const target = { morning, noon, night }[slot]
async function setSlot(slot: TimeSlot, value: YesNo) {
const target = { morning, noon, night }[slot];
if (target.value === value) {
return
return;
}
target.value = value
loading.value = true
error.value = ''
target.value = value;
loading.value = true;
error.value = "";
try {
await loadCourseList()
await loadCourseList();
} catch (error_) {
error.value = error_ instanceof Error ? error_.message : '課程資料載入失敗,請稍後再試。'
courseList.value = []
error.value =
error_ instanceof Error
? error_.message
: "課程資料載入失敗,請稍後再試。";
courseList.value = [];
} finally {
loading.value = false
loading.value = false;
}
}
@@ -242,5 +348,5 @@ export const useCourseStore = defineStore('course', () => {
loadCourseList,
loadPreferences,
setSlot,
}
})
};
});
@@ -5,7 +5,13 @@
*/
// https://vuetifyjs.com/features/sass-variables/
@use 'vuetify/settings' with (
$body-font-family: ('line-seed', sans-serif),
$heading-font-family: ('line-seed', sans-serif)
@use "vuetify/settings" with (
$body-font-family: (
"line-seed",
sans-serif,
),
$heading-font-family: (
"line-seed",
sans-serif,
)
);
+373 -250
View File
@@ -1,105 +1,199 @@
type Cell = boolean | null
type Cell = boolean | null;
interface BlockGroup {
count: number
dataCodewords: number
count: number;
dataCodewords: number;
}
interface VersionSpec {
version: number
size: number
dataCodewords: number
eccCodewords: number
blockGroups: BlockGroup[]
capacity: number
alignment: number[]
version: number;
size: number;
dataCodewords: number;
eccCodewords: number;
blockGroups: BlockGroup[];
capacity: number;
alignment: number[];
}
export interface QrCodeMatrix {
size: number
modules: boolean[][]
size: number;
modules: boolean[][];
}
const VERSION_SPECS: VersionSpec[] = [
{ version: 1, size: 21, dataCodewords: 16, eccCodewords: 10, blockGroups: [{ count: 1, dataCodewords: 16 }], capacity: 14, alignment: [] },
{ version: 2, size: 25, dataCodewords: 28, eccCodewords: 16, blockGroups: [{ count: 1, dataCodewords: 28 }], capacity: 26, alignment: [6, 18] },
{ version: 3, size: 29, dataCodewords: 44, eccCodewords: 26, blockGroups: [{ count: 1, dataCodewords: 44 }], capacity: 42, alignment: [6, 22] },
{ version: 4, size: 33, dataCodewords: 64, eccCodewords: 18, blockGroups: [{ count: 2, dataCodewords: 32 }], capacity: 62, alignment: [6, 26] },
{ version: 5, size: 37, dataCodewords: 86, eccCodewords: 24, blockGroups: [{ count: 2, dataCodewords: 43 }], capacity: 84, alignment: [6, 30] },
{ version: 6, size: 41, dataCodewords: 108, eccCodewords: 16, blockGroups: [{ count: 4, dataCodewords: 27 }], capacity: 106, alignment: [6, 34] },
{ version: 7, size: 45, dataCodewords: 124, eccCodewords: 18, blockGroups: [{ count: 4, dataCodewords: 31 }], capacity: 122, alignment: [6, 22, 38] },
{ version: 8, size: 49, dataCodewords: 154, eccCodewords: 22, blockGroups: [{ count: 2, dataCodewords: 38 }, { count: 2, dataCodewords: 39 }], capacity: 152, alignment: [6, 24, 42] },
{ version: 9, size: 53, dataCodewords: 182, eccCodewords: 22, blockGroups: [{ count: 3, dataCodewords: 36 }, { count: 2, dataCodewords: 37 }], capacity: 180, alignment: [6, 26, 46] },
{ version: 10, size: 57, dataCodewords: 216, eccCodewords: 26, blockGroups: [{ count: 4, dataCodewords: 43 }, { count: 1, dataCodewords: 44 }], capacity: 213, alignment: [6, 28, 50] },
]
{
version: 1,
size: 21,
dataCodewords: 16,
eccCodewords: 10,
blockGroups: [{ count: 1, dataCodewords: 16 }],
capacity: 14,
alignment: [],
},
{
version: 2,
size: 25,
dataCodewords: 28,
eccCodewords: 16,
blockGroups: [{ count: 1, dataCodewords: 28 }],
capacity: 26,
alignment: [6, 18],
},
{
version: 3,
size: 29,
dataCodewords: 44,
eccCodewords: 26,
blockGroups: [{ count: 1, dataCodewords: 44 }],
capacity: 42,
alignment: [6, 22],
},
{
version: 4,
size: 33,
dataCodewords: 64,
eccCodewords: 18,
blockGroups: [{ count: 2, dataCodewords: 32 }],
capacity: 62,
alignment: [6, 26],
},
{
version: 5,
size: 37,
dataCodewords: 86,
eccCodewords: 24,
blockGroups: [{ count: 2, dataCodewords: 43 }],
capacity: 84,
alignment: [6, 30],
},
{
version: 6,
size: 41,
dataCodewords: 108,
eccCodewords: 16,
blockGroups: [{ count: 4, dataCodewords: 27 }],
capacity: 106,
alignment: [6, 34],
},
{
version: 7,
size: 45,
dataCodewords: 124,
eccCodewords: 18,
blockGroups: [{ count: 4, dataCodewords: 31 }],
capacity: 122,
alignment: [6, 22, 38],
},
{
version: 8,
size: 49,
dataCodewords: 154,
eccCodewords: 22,
blockGroups: [
{ count: 2, dataCodewords: 38 },
{ count: 2, dataCodewords: 39 },
],
capacity: 152,
alignment: [6, 24, 42],
},
{
version: 9,
size: 53,
dataCodewords: 182,
eccCodewords: 22,
blockGroups: [
{ count: 3, dataCodewords: 36 },
{ count: 2, dataCodewords: 37 },
],
capacity: 180,
alignment: [6, 26, 46],
},
{
version: 10,
size: 57,
dataCodewords: 216,
eccCodewords: 26,
blockGroups: [
{ count: 4, dataCodewords: 43 },
{ count: 1, dataCodewords: 44 },
],
capacity: 213,
alignment: [6, 28, 50],
},
];
const FORMAT_XOR = 0x54_12
const FORMAT_POLY = 0x5_37
const VERSION_POLY = 0x1f_25
const FORMAT_XOR = 0x54_12;
const FORMAT_POLY = 0x5_37;
const VERSION_POLY = 0x1f_25;
const gfExp = Array.from({ length: 512 }, () => 0)
const gfLog = Array.from({ length: 256 }, () => 0)
let x = 1
const gfExp = Array.from({ length: 512 }, () => 0);
const gfLog = Array.from({ length: 256 }, () => 0);
let x = 1;
for (let i = 0; i < 255; i += 1) {
gfExp[i] = x
gfLog[x] = i
x <<= 1
gfExp[i] = x;
gfLog[x] = i;
x <<= 1;
if (x & 0x1_00) {
x ^= 0x1_1d
x ^= 0x1_1d;
}
}
for (let i = 255; i < 512; i += 1) {
gfExp[i] = gfExp[i - 255]
gfExp[i] = gfExp[i - 255];
}
export function createQrCodeMatrix (value: string): QrCodeMatrix {
const data = [...new TextEncoder().encode(value)]
const spec = VERSION_SPECS.find(item => data.length <= item.capacity)
export function createQrCodeMatrix(value: string): QrCodeMatrix {
const data = [...new TextEncoder().encode(value)];
const spec = VERSION_SPECS.find((item) => data.length <= item.capacity);
if (!spec) {
throw new Error('QR Code 內容過長,請將 payload 控制在 213 bytes 以內')
throw new Error("QR Code 內容過長,請將 payload 控制在 213 bytes 以內");
}
const dataCodewords = createDataCodewords(data, spec)
const codewords = createFinalCodewords(dataCodewords, spec)
const dataBits = codewords.flatMap(codeword => byteToBits(codeword))
const base = createBaseMatrix(spec)
const dataCodewords = createDataCodewords(data, spec);
const codewords = createFinalCodewords(dataCodewords, spec);
const dataBits = codewords.flatMap((codeword) => byteToBits(codeword));
const base = createBaseMatrix(spec);
let bestModules = base.modules
let bestScore = Number.POSITIVE_INFINITY
let bestModules = base.modules;
let bestScore = Number.POSITIVE_INFINITY;
for (let mask = 0; mask < 8; mask += 1) {
const modules = cloneMatrix(base.modules)
placeDataBits(modules, base.reserved, dataBits, mask)
drawFormatBits(modules, spec.size, mask)
const modules = cloneMatrix(base.modules);
placeDataBits(modules, base.reserved, dataBits, mask);
drawFormatBits(modules, spec.size, mask);
if (spec.version >= 7) {
drawVersionBits(modules, spec)
drawVersionBits(modules, spec);
}
const score = getPenaltyScore(modules)
const score = getPenaltyScore(modules);
if (score < bestScore) {
bestScore = score
bestModules = modules
bestScore = score;
bestModules = modules;
}
}
return {
size: spec.size,
modules: bestModules.map(row => row.map(Boolean)),
}
modules: bestModules.map((row) => row.map(Boolean)),
};
}
export function createQrCodeSvg (value: string, options: { scale?: number, quietZone?: number } = {}) {
const { size, modules } = createQrCodeMatrix(value)
const quietZone = options.quietZone ?? 4
const scale = options.scale ?? 6
const viewSize = size + quietZone * 2
const rects: string[] = []
export function createQrCodeSvg(
value: string,
options: { scale?: number; quietZone?: number } = {},
) {
const { size, modules } = createQrCodeMatrix(value);
const quietZone = options.quietZone ?? 4;
const scale = options.scale ?? 6;
const viewSize = size + quietZone * 2;
const rects: string[] = [];
for (const [y, row] of modules.entries()) {
for (const [x, dark] of row.entries()) {
if (dark) {
rects.push(`<rect x="${x + quietZone}" y="${y + quietZone}" width="1" height="1"/>`)
rects.push(
`<rect x="${x + quietZone}" y="${y + quietZone}" width="1" height="1"/>`,
);
}
}
}
@@ -109,433 +203,462 @@ export function createQrCodeSvg (value: string, options: { scale?: number, quiet
'<rect width="100%" height="100%" fill="#fff"/>',
'<g fill="#000">',
...rects,
'</g>',
'</svg>',
].join('')
"</g>",
"</svg>",
].join("");
}
function createDataCodewords (data: number[], spec: VersionSpec) {
const bits: number[] = []
appendBits(bits, 0b0100, 4)
appendBits(bits, data.length, spec.version < 10 ? 8 : 16)
function createDataCodewords(data: number[], spec: VersionSpec) {
const bits: number[] = [];
appendBits(bits, 0b0100, 4);
appendBits(bits, data.length, spec.version < 10 ? 8 : 16);
for (const byte of data) {
appendBits(bits, byte, 8)
appendBits(bits, byte, 8);
}
const capacityBits = spec.dataCodewords * 8
appendBits(bits, 0, Math.min(4, capacityBits - bits.length))
const capacityBits = spec.dataCodewords * 8;
appendBits(bits, 0, Math.min(4, capacityBits - bits.length));
while (bits.length % 8 !== 0) {
bits.push(0)
bits.push(0);
}
const codewords: number[] = []
const codewords: number[] = [];
for (let i = 0; i < bits.length; i += 8) {
codewords.push(bitsToByte(bits.slice(i, i + 8)))
codewords.push(bitsToByte(bits.slice(i, i + 8)));
}
for (let pad = 0xec; codewords.length < spec.dataCodewords; pad ^= 0xec ^ 0x11) {
codewords.push(pad)
for (
let pad = 0xec;
codewords.length < spec.dataCodewords;
pad ^= 0xec ^ 0x11
) {
codewords.push(pad);
}
return codewords
return codewords;
}
function createFinalCodewords (dataCodewords: number[], spec: VersionSpec) {
const blocks: { data: number[], ecc: number[] }[] = []
let offset = 0
function createFinalCodewords(dataCodewords: number[], spec: VersionSpec) {
const blocks: { data: number[]; ecc: number[] }[] = [];
let offset = 0;
for (const group of spec.blockGroups) {
for (let i = 0; i < group.count; i += 1) {
const data = dataCodewords.slice(offset, offset + group.dataCodewords)
offset += group.dataCodewords
blocks.push({ data, ecc: createErrorCorrectionCodewords(data, spec.eccCodewords) })
const data = dataCodewords.slice(offset, offset + group.dataCodewords);
offset += group.dataCodewords;
blocks.push({
data,
ecc: createErrorCorrectionCodewords(data, spec.eccCodewords),
});
}
}
const result: number[] = []
const maxDataLength = Math.max(...blocks.map(block => block.data.length))
const result: number[] = [];
const maxDataLength = Math.max(...blocks.map((block) => block.data.length));
for (let i = 0; i < maxDataLength; i += 1) {
for (const block of blocks) {
if (i < block.data.length) {
result.push(block.data[i])
result.push(block.data[i]);
}
}
}
for (let i = 0; i < spec.eccCodewords; i += 1) {
for (const block of blocks) {
result.push(block.ecc[i])
result.push(block.ecc[i]);
}
}
return result
return result;
}
function createErrorCorrectionCodewords (data: number[], degree: number) {
const generator = createGeneratorPolynomial(degree)
const result = Array.from({ length: degree }, () => 0)
function createErrorCorrectionCodewords(data: number[], degree: number) {
const generator = createGeneratorPolynomial(degree);
const result = Array.from({ length: degree }, () => 0);
for (const byte of data) {
const factor = byte ^ result.shift()!
result.push(0)
const factor = byte ^ result.shift()!;
result.push(0);
for (const [index, coefficient] of generator.entries()) {
result[index] ^= gfMultiply(coefficient, factor)
result[index] ^= gfMultiply(coefficient, factor);
}
}
return result
return result;
}
function createGeneratorPolynomial (degree: number) {
let result = [1]
function createGeneratorPolynomial(degree: number) {
let result = [1];
for (let i = 0; i < degree; i += 1) {
const next = Array.from({ length: result.length + 1 }, () => 0)
const next = Array.from({ length: result.length + 1 }, () => 0);
for (const [index, coefficient] of result.entries()) {
next[index] ^= gfMultiply(coefficient, 1)
next[index + 1] ^= gfMultiply(coefficient, gfExp[i])
next[index] ^= gfMultiply(coefficient, 1);
next[index + 1] ^= gfMultiply(coefficient, gfExp[i]);
}
result = next
result = next;
}
return result.slice(1)
return result.slice(1);
}
function createBaseMatrix (spec: VersionSpec) {
function createBaseMatrix(spec: VersionSpec) {
const modules = Array.from({ length: spec.size }, (): Cell[] => {
return Array.from({ length: spec.size }, (): Cell => null)
})
return Array.from({ length: spec.size }, (): Cell => null);
});
const reserved = Array.from({ length: spec.size }, (): boolean[] => {
return Array.from({ length: spec.size }, () => false)
})
return Array.from({ length: spec.size }, () => false);
});
drawFinderPattern(modules, reserved, 3, 3)
drawFinderPattern(modules, reserved, spec.size - 4, 3)
drawFinderPattern(modules, reserved, 3, spec.size - 4)
drawTimingPatterns(modules, reserved)
drawAlignmentPatterns(modules, reserved, spec.alignment)
drawDarkModule(modules, reserved, spec.size)
reserveFormatAreas(reserved, spec.size)
drawFinderPattern(modules, reserved, 3, 3);
drawFinderPattern(modules, reserved, spec.size - 4, 3);
drawFinderPattern(modules, reserved, 3, spec.size - 4);
drawTimingPatterns(modules, reserved);
drawAlignmentPatterns(modules, reserved, spec.alignment);
drawDarkModule(modules, reserved, spec.size);
reserveFormatAreas(reserved, spec.size);
if (spec.version >= 7) {
reserveVersionAreas(reserved, spec.size)
reserveVersionAreas(reserved, spec.size);
}
return { modules, reserved }
return { modules, reserved };
}
function drawFinderPattern (modules: Cell[][], reserved: boolean[][], centerX: number, centerY: number) {
function drawFinderPattern(
modules: Cell[][],
reserved: boolean[][],
centerX: number,
centerY: number,
) {
for (let y = -4; y <= 4; y += 1) {
for (let x = -4; x <= 4; x += 1) {
const xx = centerX + x
const yy = centerY + y
const xx = centerX + x;
const yy = centerY + y;
if (!isInBounds(modules.length, xx, yy)) {
continue
continue;
}
const distance = Math.max(Math.abs(x), Math.abs(y))
modules[yy][xx] = distance !== 2 && distance !== 4
reserved[yy][xx] = true
const distance = Math.max(Math.abs(x), Math.abs(y));
modules[yy][xx] = distance !== 2 && distance !== 4;
reserved[yy][xx] = true;
}
}
}
function drawTimingPatterns (modules: Cell[][], reserved: boolean[][]) {
function drawTimingPatterns(modules: Cell[][], reserved: boolean[][]) {
for (let i = 8; i < modules.length - 8; i += 1) {
const dark = i % 2 === 0
modules[6][i] = dark
modules[i][6] = dark
reserved[6][i] = true
reserved[i][6] = true
const dark = i % 2 === 0;
modules[6][i] = dark;
modules[i][6] = dark;
reserved[6][i] = true;
reserved[i][6] = true;
}
}
function drawAlignmentPatterns (modules: Cell[][], reserved: boolean[][], positions: number[]) {
function drawAlignmentPatterns(
modules: Cell[][],
reserved: boolean[][],
positions: number[],
) {
for (const y of positions) {
for (const x of positions) {
if (reserved[y][x]) {
continue
continue;
}
for (let dy = -2; dy <= 2; dy += 1) {
for (let dx = -2; dx <= 2; dx += 1) {
const distance = Math.max(Math.abs(dx), Math.abs(dy))
modules[y + dy][x + dx] = distance !== 1
reserved[y + dy][x + dx] = true
const distance = Math.max(Math.abs(dx), Math.abs(dy));
modules[y + dy][x + dx] = distance !== 1;
reserved[y + dy][x + dx] = true;
}
}
}
}
}
function drawDarkModule (modules: Cell[][], reserved: boolean[][], size: number) {
modules[size - 8][8] = true
reserved[size - 8][8] = true
function drawDarkModule(
modules: Cell[][],
reserved: boolean[][],
size: number,
) {
modules[size - 8][8] = true;
reserved[size - 8][8] = true;
}
function reserveFormatAreas (reserved: boolean[][], size: number) {
function reserveFormatAreas(reserved: boolean[][], size: number) {
for (let i = 0; i <= 5; i += 1) {
reserved[8][i] = true
reserved[i][8] = true
reserved[8][i] = true;
reserved[i][8] = true;
}
reserved[8][7] = true
reserved[8][8] = true
reserved[7][8] = true
reserved[8][7] = true;
reserved[8][8] = true;
reserved[7][8] = true;
for (let i = 0; i < 8; i += 1) {
reserved[8][size - 1 - i] = true
reserved[8][size - 1 - i] = true;
}
for (let i = 8; i < 15; i += 1) {
reserved[size - 15 + i][8] = true
reserved[size - 15 + i][8] = true;
}
}
function reserveVersionAreas (reserved: boolean[][], size: number) {
function reserveVersionAreas(reserved: boolean[][], size: number) {
for (let i = 0; i < 6; i += 1) {
for (let j = 0; j < 3; j += 1) {
reserved[size - 11 + j][i] = true
reserved[i][size - 11 + j] = true
reserved[size - 11 + j][i] = true;
reserved[i][size - 11 + j] = true;
}
}
}
function placeDataBits (modules: Cell[][], reserved: boolean[][], bits: number[], mask: number) {
const size = modules.length
let bitIndex = 0
let direction = -1
let y = size - 1
function placeDataBits(
modules: Cell[][],
reserved: boolean[][],
bits: number[],
mask: number,
) {
const size = modules.length;
let bitIndex = 0;
let direction = -1;
let y = size - 1;
for (let right = size - 1; right >= 1; right -= 2) {
if (right === 6) {
right -= 1
right -= 1;
}
while (y >= 0 && y < size) {
for (let column = 0; column < 2; column += 1) {
const x = right - column
const x = right - column;
if (!reserved[y][x]) {
const bit = bitIndex < bits.length ? bits[bitIndex] === 1 : false
modules[y][x] = bit !== getMaskBit(mask, x, y)
bitIndex += 1
const bit = bitIndex < bits.length ? bits[bitIndex] === 1 : false;
modules[y][x] = bit !== getMaskBit(mask, x, y);
bitIndex += 1;
}
}
y += direction
y += direction;
}
y -= direction
direction = -direction
y -= direction;
direction = -direction;
}
}
function drawFormatBits (modules: Cell[][], size: number, mask: number) {
const bits = getFormatBits(mask)
function drawFormatBits(modules: Cell[][], size: number, mask: number) {
const bits = getFormatBits(mask);
for (let i = 0; i <= 5; i += 1) {
setModule(modules, 8, i, getBit(bits, i))
setModule(modules, 8, i, getBit(bits, i));
}
setModule(modules, 8, 7, getBit(bits, 6))
setModule(modules, 8, 8, getBit(bits, 7))
setModule(modules, 7, 8, getBit(bits, 8))
setModule(modules, 8, 7, getBit(bits, 6));
setModule(modules, 8, 8, getBit(bits, 7));
setModule(modules, 7, 8, getBit(bits, 8));
for (let i = 9; i < 15; i += 1) {
setModule(modules, 14 - i, 8, getBit(bits, i))
setModule(modules, 14 - i, 8, getBit(bits, i));
}
for (let i = 0; i < 8; i += 1) {
setModule(modules, size - 1 - i, 8, getBit(bits, i))
setModule(modules, size - 1 - i, 8, getBit(bits, i));
}
for (let i = 8; i < 15; i += 1) {
setModule(modules, 8, size - 15 + i, getBit(bits, i))
setModule(modules, 8, size - 15 + i, getBit(bits, i));
}
}
function drawVersionBits (modules: Cell[][], spec: VersionSpec) {
const bits = getVersionBits(spec.version)
const size = spec.size
function drawVersionBits(modules: Cell[][], spec: VersionSpec) {
const bits = getVersionBits(spec.version);
const size = spec.size;
for (let i = 0; i < 18; i += 1) {
const bit = getBit(bits, i)
const x = i % 3
const y = Math.floor(i / 3)
setModule(modules, size - 11 + x, y, bit)
setModule(modules, y, size - 11 + x, bit)
const bit = getBit(bits, i);
const x = i % 3;
const y = Math.floor(i / 3);
setModule(modules, size - 11 + x, y, bit);
setModule(modules, y, size - 11 + x, bit);
}
}
function getFormatBits (mask: number) {
let data = mask
let bits = data << 10
function getFormatBits(mask: number) {
let data = mask;
let bits = data << 10;
for (let i = 4; i >= 0; i -= 1) {
if (((bits >>> (i + 10)) & 1) === 1) {
bits ^= FORMAT_POLY << i
bits ^= FORMAT_POLY << i;
}
}
data = (data << 10) | (bits & 0x03_ff)
return data ^ FORMAT_XOR
data = (data << 10) | (bits & 0x03_ff);
return data ^ FORMAT_XOR;
}
function getVersionBits (version: number) {
let bits = version << 12
function getVersionBits(version: number) {
let bits = version << 12;
for (let i = 5; i >= 0; i -= 1) {
if (((bits >>> (i + 12)) & 1) === 1) {
bits ^= VERSION_POLY << i
bits ^= VERSION_POLY << i;
}
}
return (version << 12) | (bits & 0x0f_ff)
return (version << 12) | (bits & 0x0f_ff);
}
function getPenaltyScore (modules: Cell[][]) {
const size = modules.length
let score = 0
function getPenaltyScore(modules: Cell[][]) {
const size = modules.length;
let score = 0;
for (let y = 0; y < size; y += 1) {
score += getRunPenalty(modules[y].map(Boolean))
score += getRunPenalty(modules[y].map(Boolean));
}
for (let x = 0; x < size; x += 1) {
score += getRunPenalty(modules.map(row => Boolean(row[x])))
score += getRunPenalty(modules.map((row) => Boolean(row[x])));
}
for (let y = 0; y < size - 1; y += 1) {
for (let x = 0; x < size - 1; x += 1) {
const dark = modules[y][x]
if (modules[y][x + 1] === dark && modules[y + 1][x] === dark && modules[y + 1][x + 1] === dark) {
score += 3
const dark = modules[y][x];
if (
modules[y][x + 1] === dark &&
modules[y + 1][x] === dark &&
modules[y + 1][x + 1] === dark
) {
score += 3;
}
}
}
score += getFinderLikePenalty(modules)
score += getFinderLikePenalty(modules);
const darkCount = modules.flat().filter(Boolean).length
const percent = (darkCount * 100) / (size * size)
score += Math.floor(Math.abs(percent - 50) / 5) * 10
const darkCount = modules.flat().filter(Boolean).length;
const percent = (darkCount * 100) / (size * size);
score += Math.floor(Math.abs(percent - 50) / 5) * 10;
return score
return score;
}
function getRunPenalty (line: boolean[]) {
let score = 0
let runColor = line[0]
let runLength = 1
function getRunPenalty(line: boolean[]) {
let score = 0;
let runColor = line[0];
let runLength = 1;
for (let i = 1; i < line.length; i += 1) {
if (line[i] === runColor) {
runLength += 1
runLength += 1;
} else {
if (runLength >= 5) {
score += runLength - 2
score += runLength - 2;
}
runColor = line[i]
runLength = 1
runColor = line[i];
runLength = 1;
}
}
return runLength >= 5 ? score + runLength - 2 : score
return runLength >= 5 ? score + runLength - 2 : score;
}
function getFinderLikePenalty (modules: Cell[][]) {
const pattern = '10111010000'
const reversePattern = '00001011101'
const size = modules.length
let score = 0
function getFinderLikePenalty(modules: Cell[][]) {
const pattern = "10111010000";
const reversePattern = "00001011101";
const size = modules.length;
let score = 0;
const scan = (line: boolean[]) => {
const text = line.map(bit => (bit ? '1' : '0')).join('')
const text = line.map((bit) => (bit ? "1" : "0")).join("");
for (let i = 0; i <= text.length - pattern.length; i += 1) {
const chunk = text.slice(i, i + pattern.length)
const chunk = text.slice(i, i + pattern.length);
if (chunk === pattern || chunk === reversePattern) {
score += 40
score += 40;
}
}
}
};
for (const row of modules) {
scan(row.map(Boolean))
scan(row.map(Boolean));
}
for (let x = 0; x < size; x += 1) {
scan(modules.map(row => Boolean(row[x])))
scan(modules.map((row) => Boolean(row[x])));
}
return score
return score;
}
function getMaskBit (mask: number, x: number, y: number) {
function getMaskBit(mask: number, x: number, y: number) {
switch (mask) {
case 0: {
return (x + y) % 2 === 0
return (x + y) % 2 === 0;
}
case 1: {
return y % 2 === 0
return y % 2 === 0;
}
case 2: {
return x % 3 === 0
return x % 3 === 0;
}
case 3: {
return (x + y) % 3 === 0
return (x + y) % 3 === 0;
}
case 4: {
return (Math.floor(y / 2) + Math.floor(x / 3)) % 2 === 0
return (Math.floor(y / 2) + Math.floor(x / 3)) % 2 === 0;
}
case 5: {
return ((x * y) % 2) + ((x * y) % 3) === 0
return ((x * y) % 2) + ((x * y) % 3) === 0;
}
case 6: {
return (((x * y) % 2) + ((x * y) % 3)) % 2 === 0
return (((x * y) % 2) + ((x * y) % 3)) % 2 === 0;
}
case 7: {
return (((x + y) % 2) + ((x * y) % 3)) % 2 === 0
return (((x + y) % 2) + ((x * y) % 3)) % 2 === 0;
}
default: {
return false
return false;
}
}
}
function appendBits (target: number[], value: number, length: number) {
function appendBits(target: number[], value: number, length: number) {
for (let i = length - 1; i >= 0; i -= 1) {
target.push((value >>> i) & 1)
target.push((value >>> i) & 1);
}
}
function byteToBits (value: number) {
const bits: number[] = []
appendBits(bits, value, 8)
return bits
function byteToBits(value: number) {
const bits: number[] = [];
appendBits(bits, value, 8);
return bits;
}
function bitsToByte (bits: number[]) {
return bits.reduce((value, bit) => (value << 1) | bit, 0)
function bitsToByte(bits: number[]) {
return bits.reduce((value, bit) => (value << 1) | bit, 0);
}
function gfMultiply (a: number, b: number) {
function gfMultiply(a: number, b: number) {
if (a === 0 || b === 0) {
return 0
return 0;
}
return gfExp[gfLog[a] + gfLog[b]]
return gfExp[gfLog[a] + gfLog[b]];
}
function getBit (value: number, index: number) {
return ((value >>> index) & 1) === 1
function getBit(value: number, index: number) {
return ((value >>> index) & 1) === 1;
}
function cloneMatrix (modules: Cell[][]) {
return modules.map(row => [...row])
function cloneMatrix(modules: Cell[][]) {
return modules.map((row) => [...row]);
}
function setModule (modules: Cell[][], x: number, y: number, value: boolean) {
modules[y][x] = value
function setModule(modules: Cell[][], x: number, y: number, value: boolean) {
modules[y][x] = value;
}
function isInBounds (size: number, x: number, y: number) {
return x >= 0 && y >= 0 && x < size && y < size
function isInBounds(size: number, x: number, y: number) {
return x >= 0 && y >= 0 && x < size && y < size;
}
+3 -11
View File
@@ -1,22 +1,14 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"include": [
"env.d.ts",
"src/**/*",
"src/**/*.vue"
],
"exclude": [
"src/**/__tests__/*"
],
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
"exclude": ["src/**/__tests__/*"],
"compilerOptions": {
"composite": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"baseUrl": ".",
"rootDir": ".",
"paths": {
"@/*": [
"./src/*"
]
"@/*": ["./src/*"]
}
},
"vueCompilerOptions": {
+14 -14
View File
@@ -1,7 +1,7 @@
import { fileURLToPath, URL } from 'node:url'
import Vue from '@vitejs/plugin-vue'
import { defineConfig, loadEnv } from 'vite'
import Vuetify, { transformAssetUrls } from 'vite-plugin-vuetify'
import { fileURLToPath, URL } from "node:url";
import Vue from "@vitejs/plugin-vue";
import { defineConfig, loadEnv } from "vite";
import Vuetify, { transformAssetUrls } from "vite-plugin-vuetify";
// https://vitejs.dev/config/
export default defineConfig(({ mode }) => {
@@ -9,8 +9,8 @@ export default defineConfig(({ mode }) => {
// 本機開發改用 dev server 轉發 /api,避開瀏覽器的跨網域限制。
// 在 .env.local 設定 API_PROXY_TARGET(例如內網後端位址)即可啟用;
// 未設定時維持原行為(前端走 VITE_USE_MOCK 的模擬資料)。
const env = loadEnv(mode, process.cwd(), '')
const apiProxyTarget = env.API_PROXY_TARGET
const env = loadEnv(mode, process.cwd(), "");
const apiProxyTarget = env.API_PROXY_TARGET;
return {
plugins: [
@@ -21,28 +21,28 @@ export default defineConfig(({ mode }) => {
Vuetify({
autoImport: true,
styles: {
configFile: 'src/styles/settings.scss',
configFile: "src/styles/settings.scss",
},
}),
],
define: { 'process.env': {} },
define: { "process.env": {} },
resolve: {
alias: {
'@': fileURLToPath(new URL('src', import.meta.url)),
"@": 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,
proxy: apiProxyTarget
? {
'/api': {
"/api": {
target: apiProxyTarget,
changeOrigin: true,
rewrite: (path: string) => path.replace(/^\/api/, ''),
rewrite: (path: string) => path.replace(/^\/api/, ""),
},
}
: undefined,
},
}
})
};
});
+222 -1523
View File
File diff suppressed because it is too large Load Diff