feat(app): introduce Hall of Fame section and refactor UI

This commit adds the '名人堂' (Hall of Fame) feature and removes the Element Plus dependency.

- feat(content): Add Hall of Fame section with a new content collection, homepage component, and detail pages.
- refactor(join-us): Rewrite the 'Join Us' form to remove Element Plus, using native elements and Reka UI. The form is
temporarily disabled.
- feat(ui): Display cover images on News and Events cards.
- chore: Integrate Umami for web analytics.
- fix: Correct minor text issues, including graduation year in the footer.
This commit is contained in:
xiaomai
2025-10-03 10:32:03 +08:00
parent e93b931288
commit 09ec61e315
20 changed files with 546 additions and 251 deletions

View File

@@ -13,7 +13,7 @@
<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">18 级毕业学长麦祖奕</span>
<span class="mt-1 text-sm text-gray-400">2018 级毕业学长麦祖奕</span>
</p>
</div>

View File

@@ -11,7 +11,7 @@
<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="#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">

View File

@@ -2,7 +2,7 @@
<div>
<!-- 捐赠模块 -->
<section id="donate" class="py-16 text-center bg-[var(--color-primary)]">
<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>
<a href="#" class="bg-[var(--color-secondary)] text-white px-8 py-3 rounded-xl shadow hover:opacity-90">立即捐赠</a>
</section>

View File

@@ -5,11 +5,14 @@
<div class="max-w-6xl mx-auto px-4">
<h3 class="text-2xl font-bold text-gray-900 mb-6">校友活动</h3>
<div class="grid md:grid-cols-3 gap-6">
<div v-for="event in events" :key="event.id" class="bg-white shadow rounded-xl p-6">
<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-4">地点{{ event.location }}</p>
<a :href="event.path" class="bg-secondary text-white px-5 py-2 rounded-lg hover:opacity-90">阅读详情</a>
<div v-for="event in events" :key="event.id" class="bg-white shadow rounded-xl">
<img :src="event.cover" :alt="event.title" class="rounded-xl" />
<div class="p-6">
<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-4">地点{{ event.location }}</p>
<a :href="event.path" class="bg-secondary text-white px-5 py-2 rounded-lg hover:opacity-90">阅读详情</a>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,31 @@
<template>
<div>
<section id="hall-of-fame" class="py-16">
<div class="max-w-6xl mx-auto px-4">
<h3 class="text-2xl font-bold text-center text-gray-900 mb-6">名人堂</h3>
<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)">
<img :src="person.photo" :alt="person.name" class="w-40 rounded-full border-secondary border-4" />
<h4 class="text-lg font-bold">{{ person.name }}</h4>
<p class="text-sm text-gray-500">{{ person.title }}</p>
</div>
</div>
</div>
</section>
</div>
</template>
<script lang="ts" setup>
const { data: persons } = await useAsyncData('hall-of-fames', () =>
queryCollection('hallOfFames')
.limit(4)
.all()
)
var router = useRouter()
const jumpToPersonIntro = (path: string) => {
router.push(path)
}
</script>
<style></style>

View File

@@ -18,10 +18,10 @@
<a href="/join-us" class="bg-secondary text-white px-6 py-3 rounded-xl shadow hover:opacity-90">
立即加入我们
</a>
<a href="#donate"
<!-- <a href="#donate"
class="bg-white border-2 border-secondary text-secondary px-6 py-3 rounded-xl hover:bg-secondary hover:text-white">
支持捐赠
</a>
</a> -->
</div>
</div>
</section>

View File

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

View File

@@ -0,0 +1,45 @@
<template>
<div>
<section class="py-20 px-4">
<div class="container mx-auto max-w-6xl">
<!-- 内容渲染器 -->
<div class="prose prose-invert prose-lg max-w-none">
<ContentRenderer :value="person ?? {}">
<template #empty>
<div class="text-center py-12">
<p class="text-gray-400 text-xl">内容加载中...</p>
</div>
</template>
</ContentRenderer>
</div>
</div>
</section>
<!-- 媒体展示区域 -->
<section class="py-20 px-4">
<div class="mt-16 container mx-auto max-w-6xl">
<h2 class="text-3xl font-orbitron font-bold mb-8 text-center">图集</h2>
<div class="grid md:grid-cols-2 lg:grid-cols-3 gap-6 mb-12">
<div v-for="i in person?.gallery" :key="i" class="group relative overflow-hidden rounded-xl cursor-pointer">
<img :src="i" :alt="`游戏截图 ${i}`"
class="w-full h-48 object-cover transition-transform duration-500 group-hover:scale-110" />
<div
class="absolute inset-0 bg-black/0 group-hover:bg-black/30 transition-all duration-300 flex items-center justify-center">
<Icon name="mdi:like" class="w-12 h-12 text-white opacity-0 group-hover:opacity-100 transition-opacity duration-300"/>
</div>
</div>
</div>
</div>
</section>
</div>
</template>
<script lang="ts" setup>
const route = useRoute()
const { data: person } = await useAsyncData('hall-of-fames-detail', () =>
queryCollection('hallOfFames')
.path(`/hall-of-fames/${route.params.slug}`)
.first()
)
</script>
<style></style>

View File

@@ -3,6 +3,7 @@
<IndexHero />
<IndexNews />
<IndexEvents />
<IndexHallOfFame />
<IndexDonate />
<IndexAbout />
</div>

View File

@@ -1,4 +1,118 @@
<script setup lang="ts">
import { ref, reactive, computed, defineComponent, h } from 'vue';
import { Icon } from '@iconify/vue';
import { vMaska } from "maska/vue";
// Reka primitive parts we actually need
import {
Label,
CheckboxRoot,
CheckboxIndicator,
RadioGroupRoot,
RadioGroupItem,
RadioGroupIndicator,
} from 'reka-ui';
/**
* Local lightweight FormField wrapper:
* - props: label, error, for
* - renders: <Label for="..."> + slot(default) + error paragraph
*/
const FormField = defineComponent({
name: 'FormField',
props: {
label: { type: String, required: false },
error: { type: String, required: false },
for: { type: String, required: false },
},
setup(props, { slots }) {
return () =>
h(
'div',
{ class: 'grid gap-2' },
[
props.label ? h(Label, { for: props.for }, () => props.label) : null,
slots.default ? slots.default() : null,
props.error
? h('p', { class: 'text-sm text-red-600 mt-1' }, () => props.error)
: null,
].filter(Boolean)
);
},
});
// --- form state & helpers ---
const currentYear = new Date().getFullYear();
const form = reactive({
chineseName: '',
englishName: '',
ic: '',
email: '',
phone: '',
gradYear: null as number | null,
unknownGradYear: false,
educationLevel: '',
maritalStatus: '',
country: '',
address: '',
});
const errors = reactive<Record<string, string>>({});
const toUpperCaseEnglish = () => {
form.englishName = (form.englishName || '').toUpperCase();
};
const graduationBatch = computed(() => {
if (form.gradYear) {
if (form.educationLevel === '高中毕业') {
return form.gradYear - 1965;
} else if (form.educationLevel === '初中毕业') {
return form.gradYear - 1958;
}
}
return null;
});
const validate = () => {
errors.chineseName = !form.chineseName ? '请输入中文姓名' : '';
errors.englishName = !form.englishName ? '请输入英文姓名' : '';
errors.ic = /^\d{6}-\d{2}-\d{4}$/.test(form.ic) ? '' : '格式应为 000000-00-0000';
errors.email =
(!form.email && form.country !== '马来西亚')
? '国外居住必须填写电邮'
: form.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email)
? '请输入有效的电邮地址'
: '';
errors.phone =
!/^01\d{1}-\d{7,8}$/.test(form.phone) && !/^\+\d{1,3}\s?\d+$/.test(form.phone)
? '请输入马来西亚号 (01x-xxxxxxx) 或带区号的号码'
: '';
errors.educationLevel = !form.educationLevel ? '请选择毕业层次' : '';
errors.gradYear =
!form.unknownGradYear && !form.gradYear ? '请输入毕业年份或勾选“不详”' : '';
errors.maritalStatus = !form.maritalStatus ? '请选择婚姻状态' : '';
errors.country = !form.country ? '请选择国家' : '';
errors.address = !form.address ? '请输入详细地址' : '';
return Object.values(errors).every((e) => !e);
};
const handleSubmit = () => {
if (validate()) {
// 如果你已在根组件挂载 Reka 的 ToastProvider + useToast可替换下面 alert 的实现(见备注)
alert('提交成功!理事会将尽快联系您。');
} else {
alert('请完善表单信息');
}
};
</script>
<template>
<div class="cursor-not-allowed fixed flex items-center justify-center min-h-screen min-w-screen bg-black opacity-50">
<p class="text-white text-2xl">此功能尚未开放敬请期待谢谢</p>
</div>
<div class="min-h-screen bg-primary py-10">
<div class="max-w-3xl mx-auto p-8 bg-white rounded-2xl shadow-lg">
<h1 class="text-3xl font-bold mb-6 text-center text-secondary">
@@ -11,202 +125,181 @@
填写此表格之后会有理事联系您协商入会费事宜
</p>
<el-form :model="form" :rules="rules" ref="formRef" label-width="130px" status-icon>
<form @submit.prevent="handleSubmit" class="space-y-6">
<!-- 中文姓名 -->
<el-form-item label="中文姓名" prop="chineseName">
<el-input v-model="form.chineseName" placeholder="请输入中文姓名" />
</el-form-item>
<FormField label="中文姓名" :error="errors.chineseName" for="chineseName">
<input
id="chineseName"
v-model="form.chineseName"
class="w-full border rounded px-3 py-2"
placeholder="请输入中文姓名"
/>
</FormField>
<!-- 英文姓名 (自动转大写) -->
<el-form-item label="英文姓名" prop="englishName">
<el-input v-model="form.englishName" placeholder="请输入英文姓名" @input="toUpperCaseEnglish" />
</el-form-item>
<!-- 英文姓名 -->
<FormField label="英文姓名" :error="errors.englishName" for="englishName">
<input
id="englishName"
v-model="form.englishName"
@input="toUpperCaseEnglish"
class="w-full border rounded px-3 py-2"
placeholder="请输入英文姓名"
/>
</FormField>
<!-- IC -->
<el-form-item label="IC" prop="ic">
<el-input v-model="form.ic" placeholder="000000-00-0000" v-maska="'######-##-####'" />
</el-form-item>
<FormField label="IC" :error="errors.ic" for="ic">
<input
id="ic"
v-model="form.ic"
class="w-full border rounded px-3 py-2"
placeholder="000000-00-0000"
v-maska="'######-##-####'"
/>
</FormField>
<!-- 电邮 -->
<el-form-item label="电邮" prop="email">
<el-input v-model="form.email" placeholder="选填 / 国外必填"
v-maska="['###-#######', '###-########', '+#############']" />
</el-form-item>
<FormField label="电邮" :error="errors.email" for="email">
<input
id="email"
v-model="form.email"
class="w-full border rounded px-3 py-2"
placeholder="选填 / 国外必填"
/>
</FormField>
<!-- 电话 -->
<el-form-item label="电话" prop="phone">
<el-input v-model="form.phone" placeholder="请输入电话(使用 WhatsApp 号码为佳,可加入校友会群组)" />
</el-form-item>
<FormField label="电话" :error="errors.phone" for="phone">
<input
id="phone"
v-model="form.phone"
class="w-full border rounded px-3 py-2"
placeholder="请输入电话WhatsApp 号码为佳)"
/>
</FormField>
<!-- 毕业层次 -->
<el-form-item label="毕业层次" prop="educationLevel">
<el-radio-group v-model="form.educationLevel">
<el-radio label="初中毕业">初中毕业</el-radio>
<el-radio label="高中毕业">高中毕业</el-radio>
<el-radio label="辍学/转学肄业">辍学/转学肄业</el-radio>
<el-radio label="不确定">不确定</el-radio>
</el-radio-group>
</el-form-item>
<!-- 毕业层次 (使用 Reka Radio primitives) -->
<FormField label="毕业层次" :error="errors.educationLevel" for="educationLevel">
<RadioGroupRoot
v-model="form.educationLevel"
class="flex flex-col gap-2"
name="educationLevel"
>
<RadioGroupItem value="初中毕业" class="flex items-center gap-3">
<RadioGroupIndicator class="w-4 h-4 rounded-full border flex items-center justify-center">
<span class="block w-2 h-2 rounded-full bg-secondary" />
</RadioGroupIndicator>
<span>初中毕业</span>
</RadioGroupItem>
<RadioGroupItem value="高中毕业" class="flex items-center gap-3">
<RadioGroupIndicator class="w-4 h-4 rounded-full border flex items-center justify-center">
<span class="block w-2 h-2 rounded-full bg-secondary" />
</RadioGroupIndicator>
<span>高中毕业</span>
</RadioGroupItem>
<RadioGroupItem value="辍学/转学肄业" class="flex items-center gap-3">
<RadioGroupIndicator class="w-4 h-4 rounded-full border flex items-center justify-center">
<span class="block w-2 h-2 rounded-full bg-secondary" />
</RadioGroupIndicator>
<span>辍学/转学肄业</span>
</RadioGroupItem>
<RadioGroupItem value="不确定" class="flex items-center gap-3">
<RadioGroupIndicator class="w-4 h-4 rounded-full border flex items-center justify-center">
<span class="block w-2 h-2 rounded-full bg-secondary" />
</RadioGroupIndicator>
<span>不确定</span>
</RadioGroupItem>
</RadioGroupRoot>
</FormField>
<!-- 毕业年份 -->
<el-form-item label="毕业年份" prop="gradYear">
<el-input-number v-model="form.gradYear" :min="1957" :max="currentYear" :step="1"
:disabled="form.unknownGradYear" />
<el-checkbox v-model="form.unknownGradYear">毕业年份不详</el-checkbox>
<span class="text-sm text-gray-500 ml-2" v-if="graduationBatch">
您是第 <span class="font-bold">{{ graduationBatch }}</span> 届毕业生
</span>
</el-form-item>
<FormField label="毕业年份" :error="errors.gradYear" for="gradYear">
<div class="flex items-center gap-3">
<input
id="gradYear"
type="number"
v-model="form.gradYear"
:min="1957"
:max="currentYear"
:disabled="form.unknownGradYear"
class="w-32 border rounded px-3 py-2"
/>
<label class="flex items-center gap-2 select-none">
<CheckboxRoot v-model="form.unknownGradYear" class="w-5 h-5 rounded border flex items-center justify-center">
<CheckboxIndicator class="flex items-center justify-center w-full h-full">
<Icon icon="radix-icons:check" class="h-4 w-4 text-secondary" />
</CheckboxIndicator>
</CheckboxRoot>
<span>毕业年份不详</span>
</label>
<span class="text-sm text-gray-500" v-if="graduationBatch">
您是第 <span class="font-bold">{{ graduationBatch }}</span> 届毕业生
</span>
</div>
</FormField>
<!-- 婚姻状态 -->
<el-form-item label="婚姻状态" prop="maritalStatus">
<el-radio-group v-model="form.maritalStatus">
<el-radio label="未婚">未婚</el-radio>
<el-radio label="婚">已婚</el-radio>
<el-radio label="其他">其他</el-radio>
</el-radio-group>
</el-form-item>
<FormField label="婚姻状态" :error="errors.maritalStatus" for="maritalStatus">
<div class="flex flex-col gap-2">
<RadioGroupRoot v-model="form.maritalStatus" name="maritalStatus">
<RadioGroupItem value="婚" class="flex items-center gap-3">
<RadioGroupIndicator class="w-4 h-4 rounded-full border flex items-center justify-center">
<span class="block w-2 h-2 rounded-full bg-secondary" />
</RadioGroupIndicator>
<span>未婚</span>
</RadioGroupItem>
<RadioGroupItem value="已婚" class="flex items-center gap-3">
<RadioGroupIndicator class="w-4 h-4 rounded-full border flex items-center justify-center">
<span class="block w-2 h-2 rounded-full bg-secondary" />
</RadioGroupIndicator>
<span>已婚</span>
</RadioGroupItem>
<RadioGroupItem value="其他" class="flex items-center gap-3">
<RadioGroupIndicator class="w-4 h-4 rounded-full border flex items-center justify-center">
<span class="block w-2 h-2 rounded-full bg-secondary" />
</RadioGroupIndicator>
<span>其他</span>
</RadioGroupItem>
</RadioGroupRoot>
</div>
</FormField>
<!-- 国家选择 -->
<el-form-item label="国家" prop="country">
<el-select v-model="form.country" placeholder="请选择国家">
<el-option label="马来西亚" value="马来西亚" />
<el-option label="新加坡" value="新加坡" />
<el-option label="中国" value="中国" />
<el-option label="美国" value="美国" />
<el-option label="其他" value="其他" />
</el-select>
</el-form-item>
<!-- 国家原生 select简单且稳定 -->
<FormField label="国家" :error="errors.country" for="country">
<select id="country" v-model="form.country" class="w-full border rounded px-3 py-2">
<option value="" disabled>请选择国家</option>
<option>马来西亚</option>
<option>新加坡</option>
<option>中国</option>
<option>美国</option>
<option>其他</option>
</select>
</FormField>
<!-- 详细地址 -->
<el-form-item label="详细地址" prop="address">
<el-input type="textarea" v-model="form.address" placeholder="请输入现居详细地址" />
</el-form-item>
<FormField label="详细地址" :error="errors.address" for="address">
<textarea
id="address"
v-model="form.address"
class="w-full border rounded px-3 py-2"
placeholder="请输入现居详细地址"
rows="4"
/>
</FormField>
<!-- 提交按钮 -->
<div class="text-center mt-8">
<el-button type="warning"
class="bg-secondary border-none px-8 py-2 rounded-xl text-white font-bold shadow hover:scale-105 transition"
@click="handleSubmit">
<button
type="submit"
class="bg-secondary text-white font-bold px-8 py-2 rounded-xl shadow hover:scale-105 transition"
>
提交申请
</el-button>
</button>
</div>
</el-form>
</form>
</div>
</div>
</template>
<script setup>
import { ref, reactive } from "vue";
import { ElMessage } from "element-plus";
import { vMaska } from 'maska/vue'
const currentYear = new Date().getFullYear();
const formRef = ref(null);
const form = reactive({
chineseName: "",
englishName: "",
ic: "",
email: "",
phone: "",
gradYear: null,
maritalStatus: "",
country: "",
address: "",
});
// 英文名自动转大写
const toUpperCaseEnglish = () => {
form.englishName = form.englishName.toUpperCase();
};
// 1966 年为第一届高中生毕业年份。由此根据 gradYear 计算毕业届别
const graduationBatch = computed(() => {
if (form.gradYear) {
if (form.educationLevel === "高中毕业") {
return form.gradYear - 1965; // 高中届别
} else if (form.educationLevel === "初中毕业") {
return form.gradYear - 1958; // 假设 1959 第一届初中
}
}
return null;
});
// 表单验证规则
const rules = {
chineseName: [{ required: true, message: "请输入中文姓名", trigger: "blur" }],
englishName: [{ required: true, message: "请输入英文姓名", trigger: "blur" }],
ic: [
{ required: true, message: "请输入 IC", trigger: "blur" },
{
pattern: /^\d{6}-\d{2}-\d{4}$/,
message: "格式应为 000000-00-0000",
trigger: "blur",
},
],
email: [
{
validator: (rule, value, callback) => {
if (!value && form.country !== "马来西亚") {
callback(new Error("国外居住必须填写电邮"));
} else if (value && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
callback(new Error("请输入有效的电邮地址"));
} else {
callback();
}
},
trigger: "blur",
},
],
phone: [
{ required: true, message: "请输入电话", trigger: "blur" },
{
validator: (rule, value, callback) => {
if (/^01\d{1}-\d{7,8}$/.test(value)) {
callback();
} else if (/^\+\d{1,3}\s?\d+$/.test(value)) {
callback();
} else {
callback(new Error("请输入马来西亚号 (01x-xxxxxxx) 或带区号的号码"));
}
},
trigger: "blur",
},
],
educationLevel: [
{ required: true, message: "请选择毕业层次", trigger: "change" },
],
gradYear: [
{
validator: (rule, value, callback) => {
if (!form.unknownGradYear && !value) {
callback(new Error("请输入毕业年份或勾选“毕业年份不详”"));
} else {
callback();
}
},
trigger: "blur",
},
],
maritalStatus: [
{ required: true, message: "请选择婚姻状态", trigger: "change" },
],
country: [{ required: true, message: "请选择国家", trigger: "change" }],
address: [{ required: true, message: "请输入详细地址", trigger: "blur" }],
};
// 提交
const handleSubmit = () => {
formRef.value.validate((valid) => {
if (valid) {
ElMessage.success("提交成功!理事会将尽快联系您。");
} else {
ElMessage.error("请完善表单信息");
}
});
};
</script>