Compare commits
2 Commits
cc7b2a7398
...
e7f2bc2c47
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e7f2bc2c47 | ||
|
|
1fedf7094c |
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>
|
||||||
|
|||||||
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>
|
||||||
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>
|
||||||
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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -3,18 +3,24 @@ import type { NavigationMenuItem } from "@nuxt/ui";
|
|||||||
export const useDashboardSidebarLinks = () => {
|
export const useDashboardSidebarLinks = () => {
|
||||||
const sidebarLinks = [
|
const sidebarLinks = [
|
||||||
[
|
[
|
||||||
{
|
|
||||||
label: "回到主站",
|
|
||||||
icon: "mdi:home",
|
|
||||||
type: "link",
|
|
||||||
to: "/",
|
|
||||||
target: "_blank"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
label: "仪表盘",
|
label: "仪表盘",
|
||||||
icon: "mdi:view-dashboard",
|
icon: "mdi:view-dashboard",
|
||||||
to: "/admin/dashboard",
|
to: "/admin/dashboard",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: "信息管理",
|
||||||
|
icon: "mdi:file-document-outline",
|
||||||
|
defaultOpen: true,
|
||||||
|
type: "trigger",
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
label: "会员籍管理",
|
||||||
|
icon: "mdi:account",
|
||||||
|
to: "/admin/manage/members",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: "内容管理",
|
label: "内容管理",
|
||||||
icon: "mdi:bookshelf",
|
icon: "mdi:bookshelf",
|
||||||
@@ -50,6 +56,15 @@ export const useDashboardSidebarLinks = () => {
|
|||||||
// ],
|
// ],
|
||||||
// },
|
// },
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
label: "回到主站",
|
||||||
|
icon: "mdi:home",
|
||||||
|
type: "link",
|
||||||
|
to: "/",
|
||||||
|
target: "_blank",
|
||||||
|
},
|
||||||
|
],
|
||||||
] satisfies NavigationMenuItem[][];
|
] satisfies NavigationMenuItem[][];
|
||||||
|
|
||||||
return { sidebarLinks };
|
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>
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div>
|
|
||||||
<slot />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script lang="ts" setup>
|
|
||||||
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
|
|
||||||
</style>
|
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div class="min-h-screen flex flex-col">
|
||||||
<!-- 导航栏 -->
|
<!-- 导航栏 -->
|
||||||
<header class="bg-white shadow-md sticky top-0 z-50">
|
<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 class="max-w-6xl mx-auto px-4 py-3 flex justify-between items-center">
|
||||||
<div>
|
<div>
|
||||||
<img class="inline w-16" src="/Logo.svg" alt="YPHS Alumni">
|
<img class="inline w-16" src="/Logo.svg" alt="YPHS Alumni" />
|
||||||
<h1 class="inline text-xl font-bold text-gray-900">
|
<h1 class="inline text-xl font-bold text-gray-900">
|
||||||
<a href="/" class="ml-4 hover:text-primary">永平中学校友会</a>
|
<a href="/" class="ml-4 hover:text-primary">永平中学校友会</a>
|
||||||
</h1>
|
</h1>
|
||||||
@@ -19,22 +19,23 @@
|
|||||||
加入
|
加入
|
||||||
<Icon name="mdi:account-plus" class="w-5 h-5" />
|
<Icon name="mdi:account-plus" class="w-5 h-5" />
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<slot />
|
<!-- 主体部分 -->
|
||||||
|
<main class="flex-1">
|
||||||
|
<slot />
|
||||||
|
</main>
|
||||||
|
|
||||||
<!-- 页脚 -->
|
<!-- 页脚 -->
|
||||||
<footer class="bg-gray-900 text-gray-300 py-6">
|
<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="max-w-6xl mx-auto px-4 flex flex-col md:flex-row justify-between items-center">
|
||||||
<!-- 左侧版权信息 -->
|
|
||||||
<div class="text-center md:text-left">
|
<div class="text-center md:text-left">
|
||||||
<p>© 2025 永平中学校友会. 保留所有权利.</p>
|
<p>© 2025 永平中学校友会. 保留所有权利.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="">
|
<div>
|
||||||
<p class="mt-1">
|
<p class="mt-1">
|
||||||
Powered by:
|
Powered by:
|
||||||
<a href="https://tootaio.com" target="_blank" class="font-semibold hover:underline" style="color: #e24545;">
|
<a href="https://tootaio.com" target="_blank" class="font-semibold hover:underline" style="color: #e24545;">
|
||||||
@@ -44,7 +45,6 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 右侧社交链接 -->
|
|
||||||
<div class="flex space-x-4 mt-3 md:mt-0">
|
<div class="flex space-x-4 mt-3 md:mt-0">
|
||||||
<a href="#">
|
<a href="#">
|
||||||
<Icon name="mdi-facebook" />
|
<Icon name="mdi-facebook" />
|
||||||
@@ -60,9 +60,3 @@
|
|||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
|
||||||
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style></style>
|
|
||||||
@@ -19,6 +19,7 @@ const pageTitle = "仪表盘"
|
|||||||
definePageMeta({
|
definePageMeta({
|
||||||
layout: "admin-dashboard",
|
layout: "admin-dashboard",
|
||||||
title: pageTitle,
|
title: pageTitle,
|
||||||
|
alias: ['/admin']
|
||||||
})
|
})
|
||||||
useHead({
|
useHead({
|
||||||
title: pageTitle
|
title: pageTitle
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div>
|
|
||||||
Login Page
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script lang="ts" setup>
|
|
||||||
definePageMeta({
|
|
||||||
layout: "admin-login"
|
|
||||||
})
|
|
||||||
</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>
|
||||||
Reference in New Issue
Block a user