Compare commits
5 Commits
b05faddfc0
...
e7f2bc2c47
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e7f2bc2c47 | ||
|
|
1fedf7094c | ||
|
|
cc7b2a7398 | ||
|
|
3254926c43 | ||
|
|
6f181d3f22 |
27
.vscode/admin-templates.vue
vendored
Normal file
27
.vscode/admin-templates.vue
vendored
Normal 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
8
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"recommendations": [
|
||||||
|
"vue.volar",
|
||||||
|
"nuxtr.nuxt-vscode-extentions",
|
||||||
|
"bradlc.vscode-tailwindcss",
|
||||||
|
"esbenp.prettier-vscode"
|
||||||
|
]
|
||||||
|
}
|
||||||
30
.vscode/settings.json
vendored
30
.vscode/settings.json
vendored
@@ -1,8 +1,24 @@
|
|||||||
{
|
{
|
||||||
"files.associations": {
|
"files.associations": {
|
||||||
"*.page-template": "vue",
|
"*.page-template": "vue",
|
||||||
"*.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"
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<UApp>
|
||||||
<NuxtLayout>
|
<NuxtLayout>
|
||||||
<NuxtPage />
|
<NuxtPage />
|
||||||
</NuxtLayout>
|
</NuxtLayout>
|
||||||
</div>
|
</UApp>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
@import "tailwindcss";
|
|
||||||
|
|
||||||
@theme {
|
|
||||||
--color-primary: #fcef91;
|
|
||||||
--color-secondary: #fb9e3a;
|
|
||||||
}
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
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>
|
||||||
59
app/components/admin/contents/news/AddModal.vue
Normal file
59
app/components/admin/contents/news/AddModal.vue
Normal 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>
|
||||||
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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
76
app/composables/useCountries.ts
Normal file
76
app/composables/useCountries.ts
Normal 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,
|
||||||
|
};
|
||||||
|
};
|
||||||
71
app/composables/useDashboardSidebarLinks.ts
Normal file
71
app/composables/useDashboardSidebarLinks.ts
Normal 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
3003
app/data/countries.ts
Normal file
File diff suppressed because it is too large
Load Diff
271
app/error.vue
Normal file
271
app/error.vue
Normal 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>
|
||||||
44
app/layouts/admin-dashboard.vue
Normal file
44
app/layouts/admin-dashboard.vue
Normal 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>
|
||||||
@@ -1,15 +1,62 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div class="min-h-screen flex flex-col">
|
||||||
<AppHeader />
|
<!-- 导航栏 -->
|
||||||
<slot />
|
<header class="bg-white shadow-md sticky top-0 z-50">
|
||||||
<AppFooter />
|
<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-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 />
|
||||||
|
</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>
|
|
||||||
27
app/pages/admin/contents/events/index.vue
Normal file
27
app/pages/admin/contents/events/index.vue
Normal 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>
|
||||||
27
app/pages/admin/contents/hall-of-fames/index.vue
Normal file
27
app/pages/admin/contents/hall-of-fames/index.vue
Normal 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>
|
||||||
34
app/pages/admin/contents/news/index.vue
Normal file
34
app/pages/admin/contents/news/index.vue
Normal 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>
|
||||||
29
app/pages/admin/dashboard/index.vue
Normal file
29
app/pages/admin/dashboard/index.vue
Normal 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>
|
||||||
165
app/pages/admin/manage/members/index.vue
Normal file
165
app/pages/admin/manage/members/index.vue
Normal 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>
|
||||||
6
app/plugins/md-editor.client.ts
Normal file
6
app/plugins/md-editor.client.ts
Normal 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)
|
||||||
|
})
|
||||||
11
package.json
11
package.json
@@ -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
2723
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user