fix: 自評去識別化

This commit is contained in:
skytek_xinliang
2026-09-29 09:42:47 +08:00
parent 7eb37a37fb
commit 86ffaf54be
5 changed files with 39 additions and 343 deletions
+2 -2
View File
@@ -209,10 +209,10 @@ const existingDraft = readGuestSelfAssessmentDraft();
const routePhone = normalizePhone(String(route.query.phone ?? ""));
const form = reactive({
phone: routePhone || existingDraft?.profile.phone || "",
phone: routePhone,
password: "",
passwordConfirmation: "",
consent: existingDraft?.profile.consent ?? false,
consent: false,
});
const loading = ref(false);
@@ -9,7 +9,7 @@
v-for="(stepName, idx) in stepNames"
:key="idx"
class="text-center"
cols="3"
cols="4"
>
<v-avatar
class="text-white font-weight-bold mb-1 elevation-1"
@@ -54,149 +54,8 @@
{{ errorMessage }}
</v-alert>
<!-- 步驟 1:基本資料 -->
<!-- 步驟 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>
<v-row>
<v-col cols="12" sm="6">
<v-text-field
v-model="profileForm.name"
class="mb-3"
color="primary"
density="comfortable"
hide-details="auto"
label="姓名"
variant="outlined"
/>
</v-col>
<v-col cols="12" sm="6">
<v-text-field
v-model="profileForm.phone"
class="mb-3"
color="primary"
density="comfortable"
hide-details="auto"
label="手機號碼"
type="tel"
variant="outlined"
/>
</v-col>
<v-col cols="12" sm="6">
<v-select
v-model="profileForm.gender"
class="mb-3"
color="primary"
density="comfortable"
hide-details="auto"
:items="genderOptions"
label="性別"
variant="outlined"
/>
</v-col>
<v-col cols="12" sm="6">
<v-date-input
v-model="birthdayDate"
class="mb-3"
color="primary"
density="comfortable"
hide-details="auto"
label="生日"
prepend-icon=""
prepend-inner-icon="mdi-calendar"
variant="outlined"
/>
</v-col>
<v-col cols="6">
<v-text-field
v-model.number="profileForm.height"
class="mb-3"
color="primary"
density="comfortable"
hide-details="auto"
label="身高 (公分)"
type="number"
variant="outlined"
/>
</v-col>
<v-col cols="6">
<v-text-field
v-model.number="profileForm.weight"
class="mb-3"
color="primary"
density="comfortable"
hide-details="auto"
label="體重 (公斤)"
type="number"
variant="outlined"
/>
</v-col>
<v-col cols="12" sm="6">
<v-text-field
v-model="profileForm.email"
class="mb-3"
color="primary"
density="comfortable"
hide-details="auto"
label="Email(選填)"
type="email"
variant="outlined"
/>
</v-col>
<v-col cols="12" sm="6">
<v-text-field
v-model="profileForm.emergencyContact"
class="mb-3"
color="primary"
density="comfortable"
hide-details="auto"
label="緊急聯絡人(選填)"
variant="outlined"
/>
</v-col>
<v-col cols="12" sm="6">
<v-text-field
v-model="profileForm.emergencyPhone"
class="mb-3"
color="primary"
density="comfortable"
hide-details="auto"
label="緊急聯絡電話(選填)"
type="tel"
variant="outlined"
/>
</v-col>
</v-row>
<v-checkbox
v-model="profileForm.consent"
class="mt-2 text-caption"
color="primary"
density="compact"
hide-details
label="我已閱讀並同意個資授權條款"
/>
</div>
<!-- 步驟 2:活動量與自評 -->
<div v-if="currentStep === 1">
<h2
class="text-title-medium font-weight-bold text-primary mb-4 d-flex align-center"
>
@@ -305,8 +164,8 @@
</v-row>
</div>
<!-- 步驟 3:身體狀況自評 -->
<div v-if="currentStep === 2">
<!-- 步驟 2:身體狀況自評 -->
<div v-if="currentStep === 1">
<h2
class="text-title-medium font-weight-bold text-primary mb-4 d-flex align-center"
>
@@ -373,8 +232,8 @@
</v-row>
</div>
<!-- 步驟 4:自評結果 -->
<div v-if="currentStep === 3" class="text-center py-4">
<!-- 步驟 3:自評結果 -->
<div v-if="currentStep === 2" class="text-center py-4">
<v-avatar
class="elevation-2 mb-4"
:color="getResultColor(resultLevel)"
@@ -444,7 +303,7 @@
<v-card-actions class="px-5 py-4 d-flex justify-between">
<v-btn
v-if="currentStep > 0 && currentStep < 3"
v-if="currentStep > 0 && currentStep < 2"
class="rounded-lg px-4"
color="primary"
variant="outlined"
@@ -456,10 +315,9 @@
<v-spacer />
<v-btn
v-if="currentStep < 2"
v-if="currentStep < 1"
class="rounded-lg px-6"
color="primary"
:disabled="currentStep === 0 && !profileForm.consent"
variant="flat"
@click="goToNextStep"
>
@@ -467,7 +325,7 @@
</v-btn>
<v-btn
v-else-if="currentStep === 2"
v-else-if="currentStep === 1"
class="rounded-lg px-6 text-white"
color="secondary"
variant="flat"
@@ -493,7 +351,7 @@
<!-- 返回按鈕 (結果頁專用) -->
<v-btn
v-if="currentStep === 3"
v-if="currentStep === 2"
block
class="rounded-lg py-2"
color="primary"
@@ -507,18 +365,16 @@
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from "vue";
import { onMounted, reactive, ref, watch } from "vue";
import { useRouter } from "vue-router";
import type {
ChronicDisease,
ExerciseFrequency,
Gender,
PainLevel,
RiskLevel,
SelfAssessmentRequest,
WantedImprovement,
} from "@/api/types";
import { CONSENT_VERSION } from "@/api/types";
import Layout from "@/components/layout/DefaultLayout.vue";
import PageHeading from "@/components/layout/PageHeading.vue";
import { APPOINTMENT_ENABLED } from "@/config/features";
@@ -535,36 +391,7 @@ const router = useRouter();
const currentStep = ref(0);
const appointmentLoading = ref(false);
const errorMessage = ref("");
const stepNames = ["基本資料", "功能狀態", "健康限制", "自評結果"];
const profileForm = reactive<{
name: string;
phone: string;
birthday: string;
gender: Gender | "";
height: number;
weight: number;
email: string;
emergencyContact: string;
emergencyPhone: string;
consent: boolean;
}>({
name: store.userProfile.name,
phone: store.userProfile.phone,
birthday: store.userProfile.birthday,
gender:
store.userProfile.gender === "男"
? "male"
: store.userProfile.gender === "女"
? "female"
: "",
height: store.userProfile.height,
weight: store.userProfile.weight,
email: store.userProfile.email,
emergencyContact: store.userProfile.emergencyContact,
emergencyPhone: store.userProfile.emergencyPhone,
consent: store.userProfile.consent,
});
const stepNames = ["功能狀態", "健康限制", "自評結果"];
const assessmentForm = reactive<{
exerciseFrequency: ExerciseFrequency | "";
@@ -582,10 +409,6 @@ const assessmentForm = reactive<{
const resultLevel = ref<RiskLevel | "">(store.selfAssessment.scoreLevel);
const genderOptions = [
{ title: "男", value: "male" as const },
{ title: "女", value: "female" as const },
];
const painOptions = [
{ title: "無", value: "none" as const },
{ title: "輕微", value: "mild" as const },
@@ -617,59 +440,21 @@ const diseaseOptions = [
{ title: "骨質疏鬆", value: "osteoporosis" as const },
];
const birthdayDate = computed<Date | null>({
get: () => parseDateString(profileForm.birthday),
set: (value) => {
profileForm.birthday = formatDateString(value);
},
});
onMounted(() => {
const draft = readGuestSelfAssessmentDraft();
if (!draft) {
return;
}
profileForm.name = draft.profile.name;
profileForm.phone = draft.profile.phone;
profileForm.birthday = draft.profile.birthday;
profileForm.gender = draft.profile.gender;
profileForm.height = draft.profile.heightCm;
profileForm.weight = draft.profile.weightKg;
profileForm.email = draft.profile.email ?? "";
profileForm.emergencyContact = draft.profile.emergencyContact ?? "";
profileForm.emergencyPhone = draft.profile.emergencyPhone ?? "";
profileForm.consent = draft.profile.consent;
assessmentForm.exerciseFrequency = draft.assessment.exerciseFrequency;
assessmentForm.wantedImprovements = [...draft.assessment.wantedImprovements];
assessmentForm.hasFalls = draft.assessment.hasFalls;
assessmentForm.painLevel = draft.assessment.painLevel;
assessmentForm.chronicDiseases = [...draft.assessment.chronicDiseases];
resultLevel.value = draft.provisionalRiskLevel;
currentStep.value = 3;
currentStep.value = 2;
});
function parseDateString(value: string) {
const [year, month, day] = value.split("-").map(Number);
if (!year || !month || !day) {
return null;
}
return new Date(year, month - 1, day);
}
function formatDateString(value: Date | null) {
if (!value) {
return "";
}
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}`;
}
function toggleImprovement(improvement: WantedImprovement) {
const index = assessmentForm.wantedImprovements.indexOf(improvement);
if (index === -1) {
@@ -699,26 +484,9 @@ function goToNextStep() {
scrollToPageTop();
}
watch(profileForm, clearErrorMessage);
watch(assessmentForm, clearErrorMessage);
function buildAssessmentRequest(): SelfAssessmentRequest | null {
if (
!profileForm.name.trim() ||
!/^09\d{8}$/.test(profileForm.phone.replace(/\D/g, "")) ||
!profileForm.birthday ||
!profileForm.gender ||
profileForm.height < 50 ||
profileForm.height > 250 ||
profileForm.weight < 20 ||
profileForm.weight > 300 ||
!profileForm.consent
) {
errorMessage.value = "請完整填寫基本資料,並同意個資授權條款。";
currentStep.value = 0;
return null;
}
if (!assessmentForm.exerciseFrequency || !assessmentForm.painLevel) {
errorMessage.value = "請完成所有自評題目後再送出。";
return null;
@@ -736,26 +504,12 @@ function buildAssessmentRequest(): SelfAssessmentRequest | null {
function handleSubmit() {
errorMessage.value = "";
const assessment = buildAssessmentRequest();
if (!assessment || !profileForm.gender) {
if (!assessment) {
return;
}
const normalizedPhone = profileForm.phone.replace(/\D/g, "");
const provisionalRiskLevel = calculateProvisionalRiskLevel(assessment);
const draft: GuestSelfAssessmentDraft = {
profile: {
name: profileForm.name.trim(),
phone: normalizedPhone,
birthday: profileForm.birthday,
gender: profileForm.gender,
heightCm: profileForm.height,
weightKg: profileForm.weight,
email: profileForm.email.trim() || null,
emergencyContact: profileForm.emergencyContact.trim() || null,
emergencyPhone: profileForm.emergencyPhone.replace(/\D/g, "") || null,
consent: profileForm.consent,
consentVersion: CONSENT_VERSION,
},
assessment,
provisionalRiskLevel,
};
@@ -763,7 +517,7 @@ function handleSubmit() {
saveGuestSelfAssessmentDraft(draft);
store.setProvisionalSelfAssessment(assessment);
resultLevel.value = provisionalRiskLevel;
currentStep.value = 3;
currentStep.value = 2;
scrollToPageTop();
}
+3 -32
View File
@@ -33,7 +33,6 @@ import {
clearGuestSelfAssessmentDraft,
readGuestSelfAssessmentDraft,
saveGuestSelfAssessmentDraft,
type GuestSelfAssessmentDraft,
} from "@/utils/guest-self-assessment";
import { getApiErrorMessage, getApiErrorStatus } from "@/utils/api-error";
import { calculateProvisionalRiskLevel } from "@/utils/self-assessment";
@@ -154,23 +153,6 @@ function applySelfAssessmentToStore(
};
}
function toProfileRequest(
profile: GuestSelfAssessmentDraft["profile"],
): ProfileRequest {
return {
name: profile.name,
birthday: profile.birthday,
gender: profile.gender,
heightCm: profile.heightCm,
weightKg: profile.weightKg,
email: profile.email,
emergencyContact: profile.emergencyContact,
emergencyPhone: profile.emergencyPhone,
consent: profile.consent,
consentVersion: profile.consentVersion,
};
}
function toProfileRequestFromStore(profile: UserProfile): ProfileRequest {
if (profile.gender !== "男" && profile.gender !== "女") {
throw new Error("請先完成性別資料");
@@ -543,10 +525,7 @@ export const useAppStore = defineStore("app", () => {
}
}
/**
* 將登入/註冊後的訪客草稿依序正式保存。
* Profile 失敗時不會呼叫自評;任一步驟失敗都保留草稿與冪等鍵。
*/
/** 將登入/註冊後的匿名訪客自評正式保存;失敗時保留草稿與冪等鍵。 */
async function persistGuestSelfAssessment() {
const draft = readGuestSelfAssessmentDraft();
if (!draft) {
@@ -563,33 +542,25 @@ export const useAppStore = defineStore("app", () => {
}
if (USE_MOCK) {
const profile: Profile = {
...toProfileRequest(draft.profile),
phone: draft.profile.phone,
updatedAt: new Date().toISOString(),
};
const assessment: SelfAssessmentResponse = {
...draft.assessment,
assessmentId: `mock-${idempotencyKey}`,
riskLevel: draft.provisionalRiskLevel,
completedAt: new Date().toISOString(),
};
userProfile.value = applyProfileToStore(profile);
setSelfAssessment(assessment);
clearGuestSelfAssessmentDraft();
return { profile, assessment };
return assessment;
}
const profile = await apiUpdateProfile(toProfileRequest(draft.profile));
const assessment = await createSelfAssessment(
draft.assessment,
idempotencyKey,
);
userProfile.value = applyProfileToStore(profile);
setSelfAssessment(assessment);
clearGuestSelfAssessmentDraft();
return { profile, assessment };
return assessment;
}
function registerAppointment(appointmentId: string) {
@@ -5,7 +5,4 @@ export {
readGuestSelfAssessmentDraft,
saveGuestSelfAssessmentDraft,
} from "./self-assessment";
export type {
GuestSelfAssessmentDraft,
GuestSelfAssessmentProfile,
} from "./self-assessment";
export type { GuestSelfAssessmentDraft } from "./self-assessment";
@@ -1,29 +1,13 @@
import type {
ChronicDisease,
ExerciseFrequency,
Gender,
PainLevel,
RiskLevel,
SelfAssessmentRequest,
WantedImprovement,
} from "@/api/types";
export interface GuestSelfAssessmentProfile {
name: string;
phone: string;
birthday: string;
gender: Gender;
heightCm: number;
weightKg: number;
email: string | null;
emergencyContact: string | null;
emergencyPhone: string | null;
consent: boolean;
consentVersion: "v1";
}
export interface GuestSelfAssessmentDraft {
profile: GuestSelfAssessmentProfile;
assessment: SelfAssessmentRequest;
provisionalRiskLevel: RiskLevel;
/** 同一份草稿重送正式自評時沿用同一個 key。 */
@@ -32,7 +16,6 @@ export interface GuestSelfAssessmentDraft {
export const GUEST_SELF_ASSESSMENT_DRAFT_KEY = "guest-self-assessment-draft";
const GENDER_VALUES: Gender[] = ["male", "female"];
const EXERCISE_FREQUENCY_VALUES: ExerciseFrequency[] = [
"none",
"oneToTwo",
@@ -79,30 +62,6 @@ function isStringArray<T extends string>(
);
}
function isNullableString(value: unknown): value is string | null {
return value === null || typeof value === "string";
}
function isValidProfile(value: unknown): value is GuestSelfAssessmentProfile {
if (!isRecord(value)) {
return false;
}
return (
typeof value.name === "string" &&
typeof value.phone === "string" &&
typeof value.birthday === "string" &&
isOneOf(GENDER_VALUES, value.gender) &&
typeof value.heightCm === "number" &&
typeof value.weightKg === "number" &&
isNullableString(value.email) &&
isNullableString(value.emergencyContact) &&
isNullableString(value.emergencyPhone) &&
typeof value.consent === "boolean" &&
value.consentVersion === "v1"
);
}
function isValidAssessment(value: unknown): value is SelfAssessmentRequest {
if (!isRecord(value)) {
return false;
@@ -123,7 +82,6 @@ function isValidDraft(value: unknown): value is GuestSelfAssessmentDraft {
}
return (
isValidProfile(value.profile) &&
isValidAssessment(value.assessment) &&
isOneOf(RISK_LEVEL_VALUES, value.provisionalRiskLevel) &&
(value.idempotencyKey === undefined ||
@@ -150,7 +108,7 @@ export function calculateProvisionalRiskLevel(
return "low";
}
/** 僅在自評完成並確認送出後保存一份完整訪客草稿。 */
/** 僅在自評完成並確認送出後保存不含身分資料的訪客草稿。 */
export function saveGuestSelfAssessmentDraft(
draft: GuestSelfAssessmentDraft,
): void {
@@ -183,7 +141,23 @@ export function readGuestSelfAssessmentDraft(): GuestSelfAssessmentDraft | null
}
const parsedDraft: unknown = JSON.parse(rawDraft);
return isValidDraft(parsedDraft) ? parsedDraft : null;
if (!isValidDraft(parsedDraft)) {
return null;
}
const sanitizedDraft: GuestSelfAssessmentDraft = {
assessment: {
...parsedDraft.assessment,
wantedImprovements: [...parsedDraft.assessment.wantedImprovements],
chronicDiseases: [...parsedDraft.assessment.chronicDiseases],
},
provisionalRiskLevel: parsedDraft.provisionalRiskLevel,
...(parsedDraft.idempotencyKey
? { idempotencyKey: parsedDraft.idempotencyKey }
: {}),
};
saveGuestSelfAssessmentDraft(sanitizedDraft);
return sanitizedDraft;
} catch {
return null;
}