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": {
|
||||
"*.page-template": "vue",
|
||||
"*.layout-template": "vue",
|
||||
"*.vue": "vue",
|
||||
"*.css": "tailwindcss"
|
||||
}
|
||||
}
|
||||
"files.associations": {
|
||||
"*.page-template": "vue",
|
||||
"*.layout-template": "vue",
|
||||
"*.vue": "vue",
|
||||
"*.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>
|
||||
<div>
|
||||
<UApp>
|
||||
<NuxtLayout>
|
||||
<NuxtPage />
|
||||
</NuxtLayout>
|
||||
</div>
|
||||
</UApp>
|
||||
</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 = () => {
|
||||
const sidebarLinks = [
|
||||
[
|
||||
{
|
||||
label: "回到主站",
|
||||
icon: "mdi:home",
|
||||
type: "link",
|
||||
to: "/",
|
||||
target: "_blank"
|
||||
},
|
||||
{
|
||||
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",
|
||||
@@ -50,6 +56,15 @@ export const useDashboardSidebarLinks = () => {
|
||||
// ],
|
||||
// },
|
||||
],
|
||||
[
|
||||
{
|
||||
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>
|
||||
@@ -1,13 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
|
||||
</script>
|
||||
|
||||
<style>
|
||||
|
||||
</style>
|
||||
@@ -1,10 +1,10 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="min-h-screen flex flex-col">
|
||||
<!-- 导航栏 -->
|
||||
<header class="bg-white shadow-md sticky top-0 z-50">
|
||||
<div class="max-w-6xl mx-auto px-4 py-3 flex justify-between items-center">
|
||||
<div>
|
||||
<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">
|
||||
<a href="/" class="ml-4 hover:text-primary">永平中学校友会</a>
|
||||
</h1>
|
||||
@@ -19,22 +19,23 @@
|
||||
加入
|
||||
<Icon name="mdi:account-plus" class="w-5 h-5" />
|
||||
</a>
|
||||
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<slot />
|
||||
<!-- 主体部分 -->
|
||||
<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 class="">
|
||||
<div>
|
||||
<p class="mt-1">
|
||||
Powered by:
|
||||
<a href="https://tootaio.com" target="_blank" class="font-semibold hover:underline" style="color: #e24545;">
|
||||
@@ -44,7 +45,6 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 右侧社交链接 -->
|
||||
<div class="flex space-x-4 mt-3 md:mt-0">
|
||||
<a href="#">
|
||||
<Icon name="mdi-facebook" />
|
||||
@@ -60,9 +60,3 @@
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
|
||||
</script>
|
||||
|
||||
<style></style>
|
||||
@@ -19,6 +19,7 @@ const pageTitle = "仪表盘"
|
||||
definePageMeta({
|
||||
layout: "admin-dashboard",
|
||||
title: pageTitle,
|
||||
alias: ['/admin']
|
||||
})
|
||||
useHead({
|
||||
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