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:
xiaomai
2025-10-22 21:40:30 +08:00
parent 1fedf7094c
commit e7f2bc2c47
11 changed files with 3902 additions and 3 deletions

View 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>

View 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>