Compare commits

..

5 Commits

Author SHA1 Message Date
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
xiaomai
1fedf7094c refactor(admin): simplify entry route to point to dashboard
The `/admin` route now directly serves the dashboard page by adding an alias, removing the need for a separate login page at that path.

- Deletes `pages/admin/index.vue` and the `admin-login` layout.
- Aliases `/admin` to `/admin/dashboard` for a more direct entry point.
- Improves the default layout with a sticky footer.
- Updates VSCode settings for better Tailwind CSS IntelliSense.
2025-10-09 09:22:21 +08:00
xiaomai
cc7b2a7398 feat(admin): integrate markdown editor for news creation
This commit replaces the basic textarea in the 'Add News' modal with a full-featured Markdown editor.

- Adds the `md-editor-v3` dependency.
- Implements the editor within a `ClientOnly` component for client-side rendering.
- Creates a Nuxt plugin to register the `MdEditor` component globally.
- Adjusts the modal to be fullscreen to provide a better user experience for content creation.
2025-10-08 10:13:18 +08:00
xiaomai
3254926c43 chore(deps): update project dependencies
Bumps several dependencies to their latest patch versions, including Nuxt, Tailwind CSS, and TypeScript. This keeps the project up-to-date with recent bug fixes and
improvements.
2025-10-08 09:14:55 +08:00
xiaomai
6f181d3f22 feat(admin): add initial admin dashboard structure
This commit introduces the foundational structure for the new admin dashboard.

- Utilizes @nuxt/ui to build the dashboard layout, including a collapsible sidebar and navigation.
- Adds initial pages for the dashboard, news, events, and hall of fame management.
- Implements a composable `useDashboardSidebarLinks` for managing sidebar navigation.
- Refactors the default layout by integrating the header and footer directly.
- Swaps the primary and secondary theme colors across the application.
2025-10-08 09:05:14 +08:00
30 changed files with 5983 additions and 1135 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"
]
}

18
.vscode/settings.json vendored
View File

@@ -4,5 +4,21 @@
"*.layout-template": "vue", "*.layout-template": "vue",
"*.vue": "vue", "*.vue": "vue",
"*.css": "tailwindcss" "*.css": "tailwindcss"
} },
"editor.quickSuggestions": {
"other": "on",
"comments": "off",
"strings": "on"
},
"tailwindCSS.classAttributes": [
"class",
"className",
"ngClass",
"class:list",
"ui"
],
"tailwindCSS.experimental.classRegex": [
["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

@@ -1,6 +0,0 @@
@import "tailwindcss";
@theme {
--color-primary: #fcef91;
--color-secondary: #fb9e3a;
}

View File

@@ -1,2 +1,9 @@
@import "./app.css";
@import "./markdown.css"; @import "./markdown.css";
@import "tailwindcss";
@import "@nuxt/ui";
@theme {
--color-primary: #fb9e3a;
--color-secondary: #fcef91;
}

View File

@@ -1,39 +0,0 @@
<template>
<!-- 页脚 -->
<footer class="bg-gray-900 text-gray-300 py-6">
<div class="max-w-6xl mx-auto px-4 flex flex-col md:flex-row justify-between items-center">
<!-- 左侧版权信息 -->
<div class="text-center md:text-left">
<p>© 2025 永平中学校友会. 保留所有权利.</p>
</div>
<div class="">
<p class="mt-1">
Powered by:
<a href="https://tootaio.com" target="_blank" class="font-semibold hover:underline" style="color: #e24545;">
Tootaio Studio
</a>
<span class="mt-1 text-sm text-gray-400">2018 级毕业学长麦祖奕</span>
</p>
</div>
<!-- 右侧社交链接 -->
<div class="flex space-x-4 mt-3 md:mt-0">
<a href="#">
<Icon name="mdi-facebook" />
</a>
<a href="#">
<Icon name="mdi-instagram" />
</a>
<a href="#">
<Icon name="mdi-gmail" />
</a>
</div>
</div>
</footer>
</template>
<script lang="ts" setup>
</script>
<style></style>

View File

@@ -1,25 +0,0 @@
<template>
<!-- 导航栏 -->
<header class="bg-white shadow-md sticky top-0 z-50">
<div class="max-w-6xl mx-auto px-4 py-3 flex justify-between items-center">
<div>
<img class="inline w-16" src="/Logo.svg" alt="YPHS Alumni">
<h1 class="inline text-xl font-bold text-gray-900">
<a href="/" class="ml-4 hover:text-secondary">永平中学校友会</a>
</h1>
</div>
<nav class="space-x-6 hidden md:flex items-center">
<a href="#news" class="hover:text-secondary">新闻</a>
<a href="#events" class="hover:text-secondary">活动</a>
<a href="#donate" class="hover:text-secondary">捐赠未开放</a>
<a href="#about" class="hover:text-secondary">关于</a>
<a href="/join-us"
class="inline-flex items-center gap-2 bg-secondary text-white px-4 py-2 rounded-xl shadow hover:opacity-90">
加入
<Icon name="mdi:account-plus" class="w-5 h-5" />
</a>
</nav>
</div>
</header>
</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,59 @@
<template>
<UModal v-model:open="open" title="撰写新闻" description="在数据库中添加一篇新闻" fullscreen :ui="{
body: 'overflow-y-auto p-6', // 防止溢出 + 内边距
footer: 'justify-end'
}">
<UButton label="撰写新闻" icon="mdi:newspaper-plus" />
<template #body>
<UForm ref="form" :schema="newsSchema" :state="newsState" @submit="onSubmit" class="space-y-4">
<UFormField label="新闻标题" name="title">
<UInput v-model="newsState.title" placeholder="请输入新闻标题" class="w-full" />
</UFormField>
<UFormField label="新闻内容" name="contents">
<ClientOnly>
<MdEditor v-model="newsState.content" />
</ClientOnly>
</UFormField>
</UForm>
</template>
<template #footer>
<UButton label="Cancel" color="neutral" variant="subtle" @click="open = false" />
<UButton label="Create" color="primary" variant="solid" @click="form.submit()" />
</template>
</UModal>
</template>
<script lang="ts" setup>
import type { FormSubmitEvent } from '@nuxt/ui';
import { MdEditor } from 'md-editor-v3';
import * as z from 'zod'
const form = ref();
const open = ref(false);
const newsSchema = z.object({
title: z.string().min(2, '标题太短了容易产生歧义,请写长一些').max(15, '标题太长了影响阅读体验,请简短一些'),
content: z.string()
})
type NewsSchema = z.output<typeof newsSchema>
const newsState = reactive<Partial<NewsSchema>>({
title: undefined,
content: undefined
})
const toast = useToast();
const onSubmit = async (event: FormSubmitEvent<NewsSchema>) => {
toast.add({
title: "成功",
description: `${event.data.title} 新闻稿已经撰写完毕`,
color: 'success'
})
open.value = false
}
</script>
<style></style>

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

@@ -1,10 +1,10 @@
<template> <template>
<div> <div>
<!-- 捐赠模块 --> <!-- 捐赠模块 -->
<section id="donate" class="py-16 text-center bg-[var(--color-primary)]"> <section id="donate" class="py-16 text-center bg-secondary">
<h3 class="text-2xl font-bold text-gray-900 mb-4">支持与捐赠功能未开放</h3> <h3 class="text-2xl font-bold text-gray-900 mb-4">支持与捐赠功能未开放</h3>
<p class="max-w-2xl mx-auto text-gray-700 mb-6">您的捐赠将用于奖学金校园建设及校友活动发展感谢您对母校的支持</p> <p class="max-w-2xl mx-auto text-gray-700 mb-6">您的捐赠将用于奖学金校园建设及校友活动发展感谢您对母校的支持</p>
<a href="#" class="bg-[var(--color-secondary)] text-white px-8 py-3 rounded-xl shadow hover:opacity-90">立即捐赠</a> <a href="#" class="bg-primary text-white px-8 py-3 rounded-xl shadow hover:opacity-90">立即捐赠</a>
</section> </section>
</div> </div>
</template> </template>

View File

@@ -11,7 +11,7 @@
<h4 class="font-semibold text-lg mb-2">{{ event.title }}</h4> <h4 class="font-semibold text-lg mb-2">{{ event.title }}</h4>
<p class="text-sm text-gray-600 mb-1">日期{{ useChineseDateFormat(event.date) }}</p> <p class="text-sm text-gray-600 mb-1">日期{{ useChineseDateFormat(event.date) }}</p>
<p class="text-sm text-gray-600 mb-4">地点{{ event.location }}</p> <p class="text-sm text-gray-600 mb-4">地点{{ event.location }}</p>
<a :href="event.path" class="bg-secondary text-white px-5 py-2 rounded-lg hover:opacity-90">阅读详情</a> <a :href="event.path" class="bg-primary text-white px-5 py-2 rounded-lg hover:opacity-90">阅读详情</a>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -5,7 +5,7 @@
<h3 class="text-2xl font-bold text-center text-gray-900 mb-6">名人堂</h3> <h3 class="text-2xl font-bold text-center text-gray-900 mb-6">名人堂</h3>
<div class="grid md:grid-cols-4 gap-6"> <div class="grid md:grid-cols-4 gap-6">
<div v-for="person in persons" :key="person.id" class="flex flex-col items-center cursor-pointer transition hover:scale-105 hover:drop-shadow-2xl hover:-translate-y-1" @click="jumpToPersonIntro(person.path)"> <div v-for="person in persons" :key="person.id" class="flex flex-col items-center cursor-pointer transition hover:scale-105 hover:drop-shadow-2xl hover:-translate-y-1" @click="jumpToPersonIntro(person.path)">
<img :src="person.photo" :alt="person.name" class="w-40 rounded-full border-secondary border-4" /> <img :src="person.photo" :alt="person.name" class="w-40 rounded-full border-primary border-4" />
<h4 class="text-lg font-bold">{{ person.name }}</h4> <h4 class="text-lg font-bold">{{ person.name }}</h4>
<p class="text-sm text-gray-500">{{ person.title }}</p> <p class="text-sm text-gray-500">{{ person.title }}</p>
</div> </div>

View File

@@ -1,7 +1,7 @@
<template> <template>
<div> <div>
<!-- Hero Banner --> <!-- Hero Banner -->
<section class="relative bg-primary py-32 md:py-48 lg:py-64 text-center bg-cover bg-center" <section class="relative bg-secondary py-32 md:py-48 lg:py-64 text-center bg-cover bg-center"
style="background-image: url('/hero-image.jpg');"> style="background-image: url('/hero-image.jpg');">
<!-- 遮罩 --> <!-- 遮罩 -->
<div class="absolute inset-0 bg-black/40"></div> <div class="absolute inset-0 bg-black/40"></div>
@@ -15,7 +15,7 @@
马来西亚柔佛永平中学校友会官方网站 马来西亚柔佛永平中学校友会官方网站
</p> </p>
<div class="mt-6 space-x-4"> <div class="mt-6 space-x-4">
<a href="/join-us" class="bg-secondary text-white px-6 py-3 rounded-xl shadow hover:opacity-90"> <a href="/join-us" class="bg-primary text-white px-6 py-3 rounded-xl shadow hover:opacity-90">
立即加入我们 立即加入我们
</a> </a>
<!-- <a href="#donate" <!-- <a href="#donate"

View File

@@ -5,11 +5,11 @@
<h2 class="text-2xl font-bold text-gray-900 mb-6">最新新闻与公告</h2> <h2 class="text-2xl font-bold text-gray-900 mb-6">最新新闻与公告</h2>
<div class="grid md:grid-cols-3 gap-6"> <div class="grid md:grid-cols-3 gap-6">
<article v-for="n in news" :key="n.id" @click="jumpToNewsDetail(n.stem)" <article v-for="n in news" :key="n.id" @click="jumpToNewsDetail(n.stem)"
class="bg-primary/10 rounded-xl shadow cursor-pointer transition transform hover:-translate-y-1 hover:scale-105 hover:shadow-xl duration-300 ease-in-out"> class="bg-secondary/10 rounded-xl shadow cursor-pointer transition transform hover:-translate-y-1 hover:scale-105 hover:shadow-xl duration-300 ease-in-out">
<img class="rounded-xl" :src="n.cover" :alt="n.title"> <img class="rounded-xl" :src="n.cover" :alt="n.title">
<div class="p-5"> <div class="p-5">
<h3 class="font-semibold mb-2">{{ n.title }}</h3> <h3 class="font-semibold mb-2">{{ n.title }}</h3>
<div class="px-1 w-max bg-primary/25 border-primary border-2 rounded-xl text-secondary text-sm mb-2">{{ <div class="px-1 w-max bg-secondary/25 border-secondary border-2 rounded-xl text-primary text-sm mb-2">{{
useChineseDateFormat(n.date) }}</div> useChineseDateFormat(n.date) }}</div>
<p class="text-sm text-gray-600">{{ n.description }}</p> <p class="text-sm text-gray-600">{{ n.description }}</p>
</div> </div>

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

@@ -0,0 +1,71 @@
import type { NavigationMenuItem } from "@nuxt/ui";
export const useDashboardSidebarLinks = () => {
const sidebarLinks = [
[
{
label: "仪表盘",
icon: "mdi:view-dashboard",
to: "/admin/dashboard",
},
{
label: "信息管理",
icon: "mdi:file-document-outline",
defaultOpen: true,
type: "trigger",
children: [
{
label: "会员籍管理",
icon: "mdi:account",
to: "/admin/manage/members",
},
],
},
{
label: "内容管理",
icon: "mdi:bookshelf",
defaultOpen: true,
type: "trigger",
children: [
{
label: "新闻",
icon: "mdi:newspaper",
to: "/admin/contents/news",
},
{
label: "活动",
icon: "mdi:event",
to: "/admin/contents/events",
},
{
label: "名人堂",
icon: "mdi:trophy-award",
to: "/admin/contents/hall-of-fames",
},
],
},
// {
// label: "Settings",
// to: "/settings",
// icon: "mdi:cog",
// defaultOpen: true,
// type: "trigger",
// children: [
// { label: "General", icon: "mdi:tune", to: "/settings", exact: true },
// { label: "Advanced", icon: "mdi:flask", to: "/settings/advanced" },
// ],
// },
],
[
{
label: "回到主站",
icon: "mdi:home",
type: "link",
to: "/",
target: "_blank",
},
],
] satisfies NavigationMenuItem[][];
return { sidebarLinks };
};

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,44 @@
<template>
<UDashboardGroup>
<UDashboardSidebar id="default" :open="sidebarOpen" collapsible resizable>
<template #header="{ collapsed }">
<div class="font-bold flex items-center">
<img src="/Logo.svg" alt="YPHS Alumni" class="h-8">
<span class="ml-2" v-if="!collapsed">永平中学校友会官网</span>
</div>
</template>
<template #default="{ collapsed }">
<UDashboardSearchButton :collapsed="collapsed" icon="mdi:magnify" class="bg-transparent ring-default" />
<UNavigationMenu :collapsed="collapsed" :items="sidebarLinks" orientation="vertical" tooltip popover />
</template>
<template #footer="{ collapsed }">
</template>
</UDashboardSidebar>
<UDashboardSearch :groups="groups" />
<slot />
</UDashboardGroup>
</template>
<script lang="ts" setup>
const sidebarOpen = ref(false);
const groups = ref([]);
const { sidebarLinks } = useDashboardSidebarLinks();
const route = useRoute();
// 🪄 自动根据路由 name 或 meta 显示标题
const pageTitle = computed(() => {
// 如果路由定义了 meta.title 优先用它,否则用 name
return route.meta.title || route.name || '首页'
})
</script>
<style></style>

View File

@@ -1,15 +1,62 @@
<template> <template>
<div class="min-h-screen flex flex-col">
<!-- 导航栏 -->
<header class="bg-white shadow-md sticky top-0 z-50">
<div class="max-w-6xl mx-auto px-4 py-3 flex justify-between items-center">
<div> <div>
<AppHeader /> <img class="inline w-16" src="/Logo.svg" alt="YPHS Alumni" />
<h1 class="inline text-xl font-bold text-gray-900">
<a href="/" class="ml-4 hover:text-primary">永平中学校友会</a>
</h1>
</div>
<nav class="space-x-6 hidden md:flex items-center">
<a href="#news" class="hover:text-primary">新闻</a>
<a href="#events" class="hover:text-primary">活动</a>
<a href="#donate" class="hover:text-primary">捐赠未开放</a>
<a href="#about" class="hover:text-primary">关于</a>
<a href="/join-us"
class="inline-flex items-center gap-2 bg-primary text-white px-4 py-2 rounded-xl shadow hover:opacity-90">
加入
<Icon name="mdi:account-plus" class="w-5 h-5" />
</a>
</nav>
</div>
</header>
<!-- 主体部分 -->
<main class="flex-1">
<slot /> <slot />
<AppFooter /> </main>
<!-- 页脚 -->
<footer class="bg-gray-900 text-gray-300 py-6">
<div class="max-w-6xl mx-auto px-4 flex flex-col md:flex-row justify-between items-center">
<div class="text-center md:text-left">
<p>© 2025 永平中学校友会. 保留所有权利.</p>
</div>
<div>
<p class="mt-1">
Powered by:
<a href="https://tootaio.com" target="_blank" class="font-semibold hover:underline" style="color: #e24545;">
Tootaio Studio
</a>
<span class="mt-1 text-sm text-gray-400">2018 级毕业学长麦祖奕</span>
</p>
</div>
<div class="flex space-x-4 mt-3 md:mt-0">
<a href="#">
<Icon name="mdi-facebook" />
</a>
<a href="#">
<Icon name="mdi-instagram" />
</a>
<a href="#">
<Icon name="mdi-gmail" />
</a>
</div>
</div>
</footer>
</div> </div>
</template> </template>
<script lang="ts" setup>
</script>
<style>
</style>

View File

@@ -0,0 +1,27 @@
<template>
<UDashboardPanel>
<template #header>
<UDashboardNavbar :title="pageTitle" toggle>
<template #leading>
<UDashboardSidebarCollapse />
</template>
</UDashboardNavbar>
</template>
<template #body>
This is events page
</template>
</UDashboardPanel>
</template>
<script lang="ts" setup>
const pageTitle = "活动列表"
definePageMeta({
layout: "admin-dashboard",
title: pageTitle
})
useHead({
title: pageTitle
})
</script>
<style></style>

View File

@@ -0,0 +1,27 @@
<template>
<UDashboardPanel>
<template #header>
<UDashboardNavbar :title="pageTitle" toggle>
<template #leading>
<UDashboardSidebarCollapse />
</template>
</UDashboardNavbar>
</template>
<template #body>
This is hall of fame page
</template>
</UDashboardPanel>
</template>
<script lang="ts" setup>
const pageTitle = "名人堂列表"
definePageMeta({
layout: "admin-dashboard",
title: pageTitle
})
useHead({
title: pageTitle
})
</script>
<style></style>

View File

@@ -0,0 +1,34 @@
<template>
<UDashboardPanel>
<template #header>
<UDashboardNavbar :title="pageTitle" toggle>
<template #leading>
<UDashboardSidebarCollapse />
</template>
<template #trailing>
<UBadge size="md" label="4" variant="outline" color="neutral" />
</template>
<template #right>
<AdminContentsNewsAddModal />
</template>
</UDashboardNavbar>
</template>
<template #body>
This is news page
</template>
</UDashboardPanel>
</template>
<script lang="ts" setup>
const pageTitle = "新闻列表"
definePageMeta({
layout: "admin-dashboard",
title: pageTitle
})
useHead({
title: pageTitle
})
</script>
<style></style>

View File

@@ -0,0 +1,29 @@
<template>
<UDashboardPanel>
<template #header>
<UDashboardNavbar :title="pageTitle" toggle>
<template #leading>
<UDashboardSidebarCollapse />
</template>
</UDashboardNavbar>
</template>
<template #body>
This is home page
</template>
</UDashboardPanel>
</template>
<script lang="ts" setup>
const pageTitle = "仪表盘"
definePageMeta({
layout: "admin-dashboard",
title: pageTitle,
alias: ['/admin']
})
useHead({
title: pageTitle
})
</script>
<style></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>

View File

@@ -0,0 +1,6 @@
import { MdEditor } from 'md-editor-v3'
import 'md-editor-v3/lib/style.css'
export default defineNuxtPlugin((nuxtApp) => {
nuxtApp.vueApp.component('MdEditor', MdEditor)
})

View File

@@ -12,19 +12,20 @@
"dependencies": { "dependencies": {
"@nuxt/content": "3.7.1", "@nuxt/content": "3.7.1",
"@nuxt/image": "1.11.0", "@nuxt/image": "1.11.0",
"@nuxt/ui": "4.0.0", "@nuxt/ui": "4.0.1",
"@nuxtjs/robots": "5.5.5", "@nuxtjs/robots": "5.5.5",
"@nuxtjs/seo": "3.2.2", "@nuxtjs/seo": "3.2.2",
"@nuxtjs/sitemap": "7.4.7", "@nuxtjs/sitemap": "7.4.7",
"@tailwindcss/vite": "^4.1.13", "@tailwindcss/vite": "^4.1.14",
"better-sqlite3": "^12.4.1", "better-sqlite3": "^12.4.1",
"element-plus": "^2.11.4", "element-plus": "^2.11.4",
"html2pdf.js": "^0.12.1", "html2pdf.js": "^0.12.1",
"maska": "^3.2.0", "maska": "^3.2.0",
"nuxt": "^4.1.2", "md-editor-v3": "^6.0.1",
"nuxt": "^4.1.3",
"reka-ui": "^2.5.1", "reka-ui": "^2.5.1",
"tailwindcss": "^4.1.13", "tailwindcss": "^4.1.14",
"typescript": "^5.9.2", "typescript": "^5.9.3",
"vue": "^3.5.22", "vue": "^3.5.22",
"vue-router": "^4.5.1", "vue-router": "^4.5.1",
"vue-sonner": "^2.0.9" "vue-sonner": "^2.0.9"

2723
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff