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.
This commit is contained in:
64
app/components/PhoneInput.vue
Normal file
64
app/components/PhoneInput.vue
Normal file
@@ -0,0 +1,64 @@
|
||||
<!-- ~/components/PhoneInput.vue -->
|
||||
<script setup lang="ts">
|
||||
import { USelectMenu, UInput } from "#components";
|
||||
import { useCountries } from "~/composables/useCountries";
|
||||
|
||||
interface Props {
|
||||
modelValue?: string;
|
||||
defaultDial?: string;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const emit = defineEmits(["update:modelValue", "update:country"]);
|
||||
|
||||
const { getAll } = useCountries();
|
||||
|
||||
// 当前选中国家
|
||||
const selectedCountry = ref(
|
||||
getAll().find((c) => c.dial === props.defaultDial)?.dial || getAll()[0]?.dial
|
||||
);
|
||||
|
||||
console.table(getAll().map(c => ({ name: c.name.cn, dial: c.dial })));
|
||||
|
||||
// 用户输入的电话号码
|
||||
const phone = ref(props.modelValue || "");
|
||||
|
||||
// 计算选项
|
||||
const countryOptions = computed(() =>
|
||||
getAll()
|
||||
.filter((c) => c.dial && c.dial.trim() !== "")
|
||||
.map((c) => ({
|
||||
label: `${c.name.cn} (${c.dial || "未知"})`,
|
||||
id: c.dial || "unknown",
|
||||
}))
|
||||
);
|
||||
|
||||
// 完整号码输出
|
||||
const fullNumber = computed(() => {
|
||||
const dial = selectedCountry.value || "";
|
||||
return `${dial}${phone.value}`;
|
||||
});
|
||||
|
||||
// 双向绑定
|
||||
watch(phone, () => emit("update:modelValue", fullNumber.value));
|
||||
watch(selectedCountry, () => emit("update:country", selectedCountry.value));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- 国家区号选择 -->
|
||||
<USelectMenu
|
||||
v-model="selectedCountry"
|
||||
value-key="id"
|
||||
:items="countryOptions"
|
||||
/>
|
||||
|
||||
<!-- 电话号码输入 -->
|
||||
<UInput
|
||||
v-model="phone"
|
||||
placeholder="输入电话号码"
|
||||
type="tel"
|
||||
class="flex-1"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
271
app/components/admin/manage/members/AddModal.vue
Normal file
271
app/components/admin/manage/members/AddModal.vue
Normal file
@@ -0,0 +1,271 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user