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.
272 lines
7.9 KiB
Vue
272 lines
7.9 KiB
Vue
<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>
|