Files
yphsalumni.org/app/components/admin/manage/members/AddModal.vue
xiaomai e7f2bc2c47 feat(admin): implement initial admin dashboard and member management
This commit introduces the foundational structure for the admin dashboard, focusing on the member management feature.
Key additions include:

- A new page at `/admin/manage/members` to display and manage members.
- An `AddModal` component with a comprehensive form for creating new members, featuring validation with Zod and input
masking.
- A reusable `PhoneInput` component with country code selection, backed by a new `useCountries` composable and a full
country dataset.
- A custom, user-friendly global error page (`error.vue`) to handle application errors gracefully.
- Updated dashboard sidebar navigation to include the new member management section.
- Added recommended VS Code extensions and settings to improve developer experience.
2025-10-22 21:40:30 +08:00

272 lines
7.9 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<UModal
v-model:open="open"
title="新增会员"
description="新增一名会员信息"
:ui="{
body: 'overflow-y-auto p-6', // 防止溢出 + 内边距
footer: 'justify-end',
}"
>
<UButton label="新增会员" icon="mdi:account-plus" />
<template #body>
<UForm
ref="alumniForm"
:schema="alumniSchema"
:state="alumniState"
@submit="onSubmit"
class="space-y-4"
>
<div class="two-col">
<UFormField label="中文姓名" name="chineseName">
<UInput
v-model="alumniState.chineseName"
placeholder="张三"
class="w-full"
/>
</UFormField>
<UFormField label="英文姓名" name="englishName">
<UInput
v-model="alumniState.englishName"
placeholder="John Doe"
class="w-full"
/>
</UFormField>
</div>
<div>
<UFormField label="身份证号码(支持旧 IC" name="icNumber">
<div class="flex items-center gap-2">
<UCheckbox
v-model="alumniState.icWithA"
label="带 A?"
class="whitespace-nowrap"
/>
<div class="relative w-full">
<UInput
v-model="alumniState.icNumber"
placeholder="1234567 或 123456-78-9101"
class="w-full pl-6"
v-maska="{ mask: icNumberMask }"
/>
</div>
</div>
</UFormField>
</div>
<div class="three-col">
<UFormField label="加入年份" name="joinedYear">
<UInput
v-model="alumniState.joinedYear"
type="number"
placeholder="例如2015"
:min="1986"
:max="new Date().getFullYear()"
class="w-full"
/>
</UFormField>
<UFormField label="收据" name="receiptNumber">
<UInput
v-model="alumniState.receiptNumber"
placeholder="1234 或 8888/1234"
v-maska="{ mask: ['####', '####/####'] }"
/>
</UFormField>
<UFormField label="收据类型" name="receiptType">
<USelect
v-model="alumniState.receiptType"
:items="receiptType"
class="w-full"
/>
</UFormField>
</div>
<div class="three-col">
<UFormField label="毕业 / 离校" name="educationStatus">
<USelect
v-model="alumniState.educationStatus"
:items="educationStatus"
class="w-full"
/>
</UFormField>
<UFormField
:label="`${alumniState.educationStatus?.endsWith('graduated') ? '毕业' : alumniState.educationStatus?.endsWith('dropout') ? '离校' : '毕业 / 离校'}年份`"
name="leaveYear"
>
<UInput
v-model="alumniState.leaveYear"
type="number"
placeholder="例如: 1998"
:min="1957"
:max="new Date().getFullYear()"
class="w-full"
/>
</UFormField>
<!-- 计算毕业届别 / 输入离校年级 -->
<div
v-if="
alumniState.educationStatus == 'junior_graduated' ||
alumniState.educationStatus == 'senior_graduated'
"
class="flex items-center h-full"
>
{{
alumniState.educationStatus == "junior_graduated" ? "初" : "高"
}}中第 {{ graduatedLevel }} 届
</div>
<UFormField
v-else-if="
alumniState.educationStatus == 'junior_dropout' ||
alumniState.educationStatus == 'senior_dropout'
"
:label="`${alumniState.educationStatus == 'junior_dropout' ? '初' : '高'}中?年级离校`"
name="dropout_level"
>
<UInput type="number" :min="1" :max="3" class="w-full" />
</UFormField>
</div>
<!-- 联系方式 -->
<div class="two-col">
<UFormField label="联系方式( WhatsApp 优先)" name="phone">
<UInput
v-model="alumniState.phoneNumber"
placeholder="国外电话需要加上区号"
class="w-full"
/>
</UFormField>
<UFormField label="电子邮箱" name="email">
<UInput
type="email"
v-model="alumniState.email"
placeholder="请输入您的电子邮箱"
class="w-full"
/>
</UFormField>
</div>
</UForm>
</template>
<template #footer>
<UButton
label="Cancel"
color="neutral"
variant="subtle"
@click="open = false"
/>
<UButton
label="Create"
color="primary"
variant="solid"
@click="alumniForm.submit()"
/>
</template>
</UModal>
</template>
<script lang="ts" setup>
import { vMaska } from "maska/vue";
import type { SelectItem, FormSubmitEvent } from "@nuxt/ui";
import * as z from "zod";
// 根据是否带 A动态调整 mask
const icNumberMask = computed(() => {
return alumniState.icWithA ? ["A#######"] : ["#######", "######-##-####"];
});
const receiptType = ref<SelectItem[]>([
{ label: "---请选择---", value: "unknown" },
{ label: "表格", value: "form" },
{ label: "收据", value: "receipt" },
]);
const educationStatus = ref<SelectItem[]>([
{ label: "---请选择---", value: "unknown" },
{ label: "初中离校", value: "junior_dropout" },
{ label: "初中毕业", value: "junior_graduated" },
{ label: "高中离校", value: "senior_dropout" },
{ label: "高中毕业", value: "senior_graduated" },
]);
const graduatedLevel = computed<number | string>(() => {
const leaveYear = Number(alumniState.leaveYear);
switch (alumniState.educationStatus) {
case "junior_graduated":
return leaveYear > 1958 ? leaveYear - 1958 : "-";
case "senior_graduated":
return leaveYear > 1965 ? leaveYear - 1965 : "-";
default:
return "-";
}
});
const alumniForm = ref();
const open = ref(false);
const alumniSchema = z.object({
chineseName: z.string().min(2, "名字似乎太短了哦").max(4, "名字似乎太长了哦"),
englishName: z.string().nullable(),
icWithA: z.boolean(),
icNumber: z
.string()
.min(7, "身份证号码最少得 7 位")
.max(14, "身份证号码最多支持 12 位")
.nullable(),
joinedYear: z.number().nullable(),
receiptNumber: z.string().nullable(),
receiptType: z.enum(["unknown", "form", "receipt"]), // 要和上面的 receiptType 相匹配
leaveYear: z.number().nullable(),
educationStatus: z.enum([
"unknown",
"junior_dropout",
"junior_graduated",
"senior_dropout",
"senior_graduated",
]),
dropout_level: z.string().min(1).max(1).nullable(),
phoneNumber: z.string(),
email: z.string().email().nullable(),
});
type AlumniSchema = z.output<typeof alumniSchema>;
const alumniState = reactive<Partial<AlumniSchema>>({
chineseName: undefined,
englishName: null,
icWithA: false,
icNumber: null,
joinedYear: new Date().getFullYear(),
receiptNumber: null,
receiptType: "unknown",
leaveYear: null,
educationStatus: "unknown",
dropout_level: null,
phoneNumber: undefined,
email: null,
});
const toast = useToast();
const onSubmit = async (event: FormSubmitEvent<AlumniSchema>) => {
console.table(event.data);
toast.add({
title: "成功",
description: `${event.data.chineseName} 新闻稿已经撰写完毕`,
color: "success",
});
open.value = false;
};
</script>
<style scoped>
@reference 'tailwindcss';
.two-col {
@apply grid grid-cols-2 gap-4;
}
.three-col {
@apply grid grid-cols-3 gap-4;
}
</style>