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

27
.vscode/admin-templates.vue vendored Normal file
View File

@@ -0,0 +1,27 @@
<template>
<UDashboardPanel>
<template #header>
<UDashboardNavbar :title="pageTitle" toggle>
<template #leading>
<UDashboardSidebarCollapse />
</template>
</UDashboardNavbar>
</template>
<template #body>
管理员界面模板
</template>
</UDashboardPanel>
</template>
<script lang="ts" setup>
const pageTitle = "管理员界面模板"
definePageMeta({
layout: "admin-dashboard",
title: pageTitle
})
useHead({
title: pageTitle
})
</script>
<style></style>

8
.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,8 @@
{
"recommendations": [
"vue.volar",
"nuxtr.nuxt-vscode-extentions",
"bradlc.vscode-tailwindcss",
"esbenp.prettier-vscode"
]
}

View File

@@ -19,5 +19,6 @@
], ],
"tailwindCSS.experimental.classRegex": [ "tailwindCSS.experimental.classRegex": [
["ui:\\s*{([^)]*)\\s*}", "(?:'|\"|`)([^']*)(?:'|\"|`)"] ["ui:\\s*{([^)]*)\\s*}", "(?:'|\"|`)([^']*)(?:'|\"|`)"]
] ],
"css.lint.unknownAtRules": "ignore"
} }

View File

@@ -1,7 +1,7 @@
<template> <template>
<div> <UApp>
<NuxtLayout> <NuxtLayout>
<NuxtPage /> <NuxtPage />
</NuxtLayout> </NuxtLayout>
</div> </UApp>
</template> </template>

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>

View File

@@ -0,0 +1,76 @@
import { countries, type Country } from "~/data/countries";
/**
* 🌍 useCountries composable
* 提供国家相关的搜索、过滤、分组、查找等功能
*/
export const useCountries = () => {
/**
* 获取全部国家
*/
const getAll = (): Country[] => countries;
/**
* 按洲分组
*/
const groupedByContinent = computed(() => {
const groups: Record<string, Country[]> = {};
for (const c of countries) {
const key = c.continent || "Unknown";
if (!groups[key]) groups[key] = [];
groups[key].push(c);
}
return groups;
});
/**
* 按名称搜索(支持多语言字段)
* @param query 搜索关键字
*/
const search = (query: string) => {
if (!query) return countries;
const q = query.toLowerCase();
return countries.filter((c) =>
Object.values(c.name).some((name) => name.toLowerCase().includes(q))
);
};
/**
* 根据国家代码查找
* @param code ISO Alpha-2 或 Alpha-3 代码
*/
const findByCode = (code: string) => {
const upper = code.toUpperCase();
return countries.find((c) => c.code === upper || c.iso3 === upper);
};
/**
* 获取特定语言的显示名称
* @param country 国家对象
* @param lang 语言代码(默认为 en
*/
const getDisplayName = (
country: Country,
lang: keyof Country["name"] = "en"
) => {
return country.name?.[lang] || country.name.en;
};
/**
* 根据洲名筛选
* @param continent 洲名(如 'Asia'、'Europe'
*/
const filterByContinent = (continent: string) => {
const key = continent.trim().toLowerCase();
return countries.filter((c) => c.continent.toLowerCase() === key);
};
return {
getAll,
groupedByContinent,
search,
findByCode,
filterByContinent,
getDisplayName,
};
};

View File

@@ -8,6 +8,19 @@ export const useDashboardSidebarLinks = () => {
icon: "mdi:view-dashboard", icon: "mdi:view-dashboard",
to: "/admin/dashboard", to: "/admin/dashboard",
}, },
{
label: "信息管理",
icon: "mdi:file-document-outline",
defaultOpen: true,
type: "trigger",
children: [
{
label: "会员籍管理",
icon: "mdi:account",
to: "/admin/manage/members",
},
],
},
{ {
label: "内容管理", label: "内容管理",
icon: "mdi:bookshelf", icon: "mdi:bookshelf",

3003
app/data/countries.ts Normal file

File diff suppressed because it is too large Load Diff

271
app/error.vue Normal file
View File

@@ -0,0 +1,271 @@
<script setup lang="ts">
import type { NuxtError } from "#app";
// 定义扩展错误类型
interface ExtendedError extends NuxtError {
url?: string;
}
const props = defineProps<{ error?: ExtendedError }>();
const router = useRouter();
const route = useRoute();
const status = computed(() => props.error?.statusCode ?? 500);
const statusMessage = computed(() => props.error?.statusMessage ?? "");
const title = computed(() => {
switch (status.value) {
case 404:
return "页面未找到";
case 403:
return "禁止访问";
case 500:
return "服务器内部错误";
default:
return `${status.value} 错误`;
}
});
useHead({ title: title.value });
const friendlyMessage = computed(() => {
if (status.value === 404)
return "抱歉,我们找不到您要访问的页面。它可能已被移动、删除或从未存在过。";
if (status.value === 403)
return "抱歉,您没有权限访问此页面。请检查您的账户权限或联系管理员。";
if (status.value === 500)
return "服务器出了点问题,我们正在努力修复。请稍后再试。";
return props.error?.message ?? "发生了未知错误,我们的技术团队已经收到通知。";
});
const goHome = () => router.push("/");
const goBack = () => {
if (import.meta.client && window.history.length > 1) router.back();
else router.push("/");
};
const reloadPage = () => {
if (import.meta.client) window.location.reload();
};
const contactSupport = () => {
if (!import.meta.client) return;
const subject = encodeURIComponent(
`网站错误反馈 — ${title.value}${route.fullPath}`
);
const body = encodeURIComponent(`错误信息:
Status: ${status.value} ${statusMessage.value}
Path: ${route.fullPath}
请描述您在做什么以及复现步骤:`);
// 请替换为真实的支持邮箱或工单链接
window.location.href = `mailto:hello@example.com?subject=${subject}&body=${body}`;
};
const isDev = import.meta.dev;
// 格式化错误信息
const formattedDebugInfo = computed(() => {
if (!props.error) return isDev ? "无错误对象传入。" : "错误详情已隐藏。";
if (isDev) {
try {
return {
status: props.error.statusCode,
message: props.error.message || props.error.statusMessage,
url: props.error.url,
stack: props.error.stack,
};
} catch (e) {
return { error: String(props.error) };
}
}
return {
status: props.error.statusCode || "N/A",
message: props.error.message || props.error.statusMessage || "N/A",
};
});
// 错误图标
const errorIcon = computed(() => {
switch (status.value) {
case 404:
return "mdi:magnify-close";
case 403:
return "mdi:lock-alert";
case 500:
return "mdi:server-off";
default:
return "mdi:alert-circle";
}
});
// 错误颜色
const errorColor = computed(() => {
switch (status.value) {
case 404:
return "#fb9e3a"; // 使用主题色
case 403:
return "#e74c3c";
case 500:
return "#c0392b";
default:
return "#fb9e3a"; // 使用主题色
}
});
</script>
<template>
<main
class="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 p-4"
>
<div
class="max-w-3xl w-full bg-white dark:bg-gray-800 shadow-lg rounded-lg overflow-hidden"
>
<!-- 顶部状态栏 -->
<div class="h-2 w-full" :style="{ backgroundColor: errorColor }"></div>
<div class="p-8 md:p-10">
<div class="flex flex-col md:flex-row items-center gap-8">
<!-- 错误代码和图标 -->
<div class="flex-none text-center">
<div class="relative">
<div
class="w-32 h-32 rounded-full flex items-center justify-center mx-auto mb-4"
:style="{ backgroundColor: errorColor + '20' }"
>
<div class="text-4xl" :style="{ color: errorColor }">
<span v-if="status === 404">404</span>
<span v-else-if="status === 403">403</span>
<span v-else-if="status === 500">500</span>
<span v-else>{{ status }}</span>
</div>
</div>
</div>
<div
class="text-sm text-gray-500 dark:text-gray-400 font-medium mt-2"
>
{{ statusMessage || title }}
</div>
</div>
<!-- 错误信息和操作 -->
<div class="flex-1">
<h1
class="text-2xl font-bold text-gray-900 dark:text-gray-100 mb-3"
>
{{ title }}
</h1>
<p class="text-gray-600 dark:text-gray-300 leading-relaxed mb-6">
{{ friendlyMessage }}
</p>
<!-- 操作按钮组 -->
<div class="flex flex-wrap gap-3 mb-6">
<button
@click="goHome"
class="px-5 py-2.5 rounded-md text-white font-medium transition-colors flex items-center gap-2"
style="background-color: var(--color-primary)"
>
<span>返回首页</span>
</button>
<button
@click="goBack"
class="px-5 py-2.5 rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 font-medium hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
>
返回上页
</button>
<button
@click="reloadPage"
class="px-5 py-2.5 rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 font-medium hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
>
重新加载
</button>
</div>
<!-- 报告问题按钮 -->
<div class="mb-6">
<button
@click="contactSupport"
class="px-5 py-2.5 rounded-md bg-gray-800 dark:bg-gray-700 text-white font-medium hover:bg-gray-700 dark:hover:bg-gray-600 transition-colors flex items-center gap-2"
>
<span>报告问题</span>
</button>
</div>
<!-- 提示信息 -->
<div class="text-sm text-gray-500 dark:text-gray-400 mb-6">
如果这是由链接或书签导致的请尝试返回或访问主页
</div>
<!-- 错误详情 -->
<details
class="border border-gray-200 dark:border-gray-700 rounded-md overflow-hidden"
>
<summary
class="cursor-pointer p-3 bg-gray-50 dark:bg-gray-900 font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
>
错误详情{{ isDev ? "开发模式" : "精简信息" }}
</summary>
<div
class="p-4 bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-200 overflow-auto max-h-64"
>
<div v-if="typeof formattedDebugInfo === 'object'">
<div v-if="isDev" class="space-y-4">
<div v-if="formattedDebugInfo.status">
<strong>状态码:</strong> {{ formattedDebugInfo.status }}
</div>
<div v-if="formattedDebugInfo.message">
<strong>消息:</strong> {{ formattedDebugInfo.message }}
</div>
<div v-if="formattedDebugInfo.url">
<strong>URL:</strong> {{ formattedDebugInfo.url }}
</div>
<div v-if="formattedDebugInfo.stack" class="mt-3">
<strong>堆栈跟踪:</strong>
<pre
class="mt-2 text-xs bg-gray-50 dark:bg-gray-900 p-3 rounded overflow-x-auto"
>{{ formattedDebugInfo.stack }}</pre
>
</div>
</div>
<div v-else>
<div v-if="formattedDebugInfo.status">
<strong>状态码:</strong> {{ formattedDebugInfo.status }}
</div>
<div v-if="formattedDebugInfo.message">
<strong>消息:</strong> {{ formattedDebugInfo.message }}
</div>
</div>
</div>
<div v-else>
{{ formattedDebugInfo }}
</div>
</div>
</details>
</div>
</div>
</div>
</div>
</main>
</template>
<style scoped>
/* 自定义样式补充 */
details[open] summary {
border-bottom: 1px solid #e5e7eb;
}
/* 暗色模式适配 */
@media (prefers-color-scheme: dark) {
details[open] summary {
border-bottom-color: #4b5563;
}
}
/* 响应式调整 */
@media (max-width: 640px) {
.text-4xl {
font-size: 2rem;
}
}
</style>

View File

@@ -0,0 +1,165 @@
<template>
<UDashboardPanel>
<template #header>
<UDashboardNavbar :title="pageTitle" toggle>
<template #leading>
<UDashboardSidebarCollapse />
</template>
<template #right>
<AdminManageMembersAddModal />
</template>
</UDashboardNavbar>
</template>
<template #body>
<UTable
:data="data"
:columns="columns"
:loading="status === 'pending'"
class="flex-1 h-80"
/>
</template>
</UDashboardPanel>
</template>
<script lang="ts" setup>
import type { TableColumn } from "@nuxt/ui";
import type { Row } from '@tanstack/vue-table'
import { useClipboard } from '@vueuse/core'
const pageTitle = "会员籍管理";
definePageMeta({
layout: "admin-dashboard",
title: pageTitle,
});
useHead({
title: pageTitle,
});
const UAvatar = resolveComponent("UAvatar");
const UButton = resolveComponent("UButton");
const UBadge = resolveComponent("UBadge");
const UDropdownMenu = resolveComponent("UDropdownMenu");
const toast = useToast()
const { copy } = useClipboard()
type User = {
id: number;
name: string;
username: string;
email: string;
avatar: { src: string };
company: { name: string };
};
const { data, status } = await useFetch<User[]>(
"https://jsonplaceholder.typicode.com/users",
{
key: "table-users",
transform: (data) => {
return (
data?.map((user) => ({
...user,
avatar: {
src: `https://i.pravatar.cc/120?img=${user.id}`,
alt: `${user.name} avatar`,
},
})) || []
);
},
lazy: true,
}
);
const columns: TableColumn<User>[] = [
{
accessorKey: "id",
header: "ID",
},
{
accessorKey: "name",
header: "Name",
cell: ({ row }) => {
return h("div", { class: "flex items-center gap-3" }, [
h(UAvatar, {
...row.original.avatar,
size: "lg",
}),
h("div", undefined, [
h("p", { class: "font-medium text-highlighted" }, row.original.name),
h("p", { class: "" }, `@${row.original.username}`),
]),
]);
},
},
{
accessorKey: "email",
header: "Email",
},
{
accessorKey: "company",
header: "Company",
cell: ({ row }) => row.original.company.name,
},
{
id: "actions",
cell: ({ row }) => {
return h(
"div",
{ class: "text-right" },
h(
UDropdownMenu,
{
content: {
align: "end",
},
items: getRowItems(row),
"aria-label": "Actions dropdown",
},
() =>
h(UButton, {
icon: "i-lucide-ellipsis-vertical",
color: "neutral",
variant: "ghost",
class: "ml-auto",
"aria-label": "Actions dropdown",
})
)
);
},
},
];
function getRowItems(row: Row<object>) {
return [
{
type: 'label',
label: 'Actions'
},
{
label: 'Copy payment ID',
onSelect() {
copy(row.original.id)
toast.add({
title: 'Payment ID copied to clipboard!',
color: 'success',
icon: 'i-lucide-circle-check'
})
}
},
{
type: 'separator'
},
{
label: 'View customer'
},
{
label: 'View payment details'
}
]
}
</script>
<style></style>