feat: add TownsPage and router configuration

- Created TownsPage.vue to display a list of towns with filtering options and a modal for details.
- Implemented a router.js file to manage application routes, including the new towns page.
This commit is contained in:
zhangyuheng
2026-03-18 14:10:49 +08:00
parent d254ec86df
commit 5c6d389962
21 changed files with 5401 additions and 449 deletions

View File

@@ -1,322 +1,34 @@
<script setup>
import { computed, ref } from 'vue';
import {
AnnouncementTimeline,
BaseBadge,
BaseButton,
BaseCard,
EmptyState,
FacilityCard,
FacilityDetailModal,
FeatureBentoGrid,
FilterPanel,
JoinWizard,
LeaderboardCard,
LoadMoreButton,
PageHero,
PlayerCard,
PlayerDetailModal,
SiteFooter,
SiteNavbar,
SponsorModal,
TownCard,
TownDetailModal,
DonationCard,
} from './components';
import {
announcementItems,
bentoItems,
donationItems,
facilityItems,
joinDevices,
leaderboardBoards,
navItems,
playerItems,
playstyles,
sponsorSummary,
townItems,
} from './demoData';
import { computed } from 'vue';
import { useRoute } from 'vue-router';
import SiteNavbar from './components/layout/SiteNavbar.vue';
import SiteFooter from './components/layout/SiteFooter.vue';
const searchValue = ref('');
const selectedFacilityType = ref('all');
const selectedFacilityDimension = ref('all');
const route = useRoute();
const facilityModalOpen = ref(false);
const townModalOpen = ref(false);
const playerModalOpen = ref(false);
const sponsorModalOpen = ref(false);
const navItems = [
{ label: '文档', href: '/doc' },
{ label: '地图', href: '/map' },
{ label: '设施', href: '/facilities' },
{ label: '城镇', href: '/towns' },
{ label: '公告', href: '/announcements' },
{ label: '相册', href: '/photo' },
{ label: '数据', href: '/stats' },
{ label: '赞助', href: '/sponsor' },
{ label: '群聊', href: 'https://qm.qq.com/q/9izlHDoef6', external: true },
];
const activeFacility = ref(facilityItems[0]);
const activeTown = ref(townItems[0]);
const activePlayer = ref(playerItems[0]);
const activePath = computed(() => route.path);
const filters = computed(() => [
{
key: 'type',
label: '类型',
modelValue: selectedFacilityType.value,
options: [
{ value: 'all', label: '全部' },
{ value: '资源', label: '资源', icon: '◈' },
{ value: '基建', label: '基建', icon: '▤' },
],
},
{
key: 'dimension',
label: '维度',
modelValue: selectedFacilityDimension.value,
options: [
{ value: 'all', label: '全部' },
{ value: '主世界', label: '主世界' },
{ value: '下界', label: '下界' },
],
},
]);
// iframe pages don't show footer; they fill the viewport
const isIframePage = computed(() =>
['/doc', '/map', '/photo'].includes(route.path)
);
const filteredFacilities = computed(() => {
const keyword = searchValue.value.trim().toLowerCase();
return facilityItems.filter((item) => {
const matchesKeyword = !keyword || `${item.title} ${item.intro}`.toLowerCase().includes(keyword);
const matchesType = selectedFacilityType.value === 'all' || item.type === selectedFacilityType.value;
const matchesDimension = selectedFacilityDimension.value === 'all' || item.dimension === selectedFacilityDimension.value;
return matchesKeyword && matchesType && matchesDimension;
});
});
const handleFilterChange = ({ key, value }) => {
if (key === 'type') {
selectedFacilityType.value = value;
}
if (key === 'dimension') {
selectedFacilityDimension.value = value;
}
};
</script>
<template>
<div class="showcase-page">
<SiteNavbar :items="navItems" active-path="/facilities.html" />
<PageHero
eyebrow="Vue UI Migration"
title="白鹿原基础 UI 组件审查页"
subtitle="已按旧站视觉语言重建共享布局、原子组件、内容卡片、时间线、向导与详情弹窗。这里仅展示组件,不迁移具体页面。"
>
<div class="hero-review-panel">
<span class="bl-demo-chip">组件数 25+</span>
<span class="bl-demo-chip">旧站风格保留</span>
<span class="bl-demo-chip">Vue 组件化</span>
</div>
</PageHero>
<main class="showcase-main bl-shell">
<section class="showcase-section">
<div class="bl-section-heading">
<div>
<p class="showcase-kicker">Layout + Base</p>
<h2 class="bl-section-title">布局原语与基础控件</h2>
</div>
<p class="bl-section-copy">映射 old-html-ver/js/components.js 与全局 style.css但统一了圆角层次和交互状态</p>
</div>
<div class="bl-grid bl-grid-3">
<BaseCard>
<h3>按钮</h3>
<div class="button-row">
<BaseButton>主要操作</BaseButton>
<BaseButton variant="secondary">次要操作</BaseButton>
<BaseButton variant="ghost">描边按钮</BaseButton>
</div>
</BaseCard>
<BaseCard>
<h3>状态徽章</h3>
<div class="badge-row">
<BaseBadge tone="accent">新模式</BaseBadge>
<BaseBadge tone="success">运行中</BaseBadge>
<BaseBadge tone="warning">维护中</BaseBadge>
<BaseBadge tone="purple">公告</BaseBadge>
</div>
</BaseCard>
<BaseCard>
<h3>占位 / 分页</h3>
<EmptyState title="组件预留位" description="后续页面迁移时可直接嵌入空状态与加载更多行为。" />
<LoadMoreButton />
</BaseCard>
</div>
</section>
<section class="showcase-section">
<div class="bl-section-heading">
<div>
<p class="showcase-kicker">Controls</p>
<h2 class="bl-section-title">Search / Filter 标准模式</h2>
</div>
<p class="bl-section-copy"> announcements / facilities / towns controls-section canonical pattern</p>
</div>
<FilterPanel
title="设施列表"
:search-value="searchValue"
search-placeholder="搜索设施标题或简介..."
:filters="filters"
action-label="新增设施"
@update:search-value="searchValue = $event"
@change-filter="handleFilterChange"
/>
<div class="bl-grid bl-grid-2 cards-grid">
<FacilityCard
v-for="facility in filteredFacilities"
:key="facility.id"
:facility="facility"
@click="activeFacility = facility; facilityModalOpen = true"
/>
</div>
</section>
<section class="showcase-section">
<div class="bl-section-heading">
<div>
<p class="showcase-kicker">Cards</p>
<h2 class="bl-section-title">设施城镇玩家赞助卡片</h2>
</div>
<p class="bl-section-copy">卡片结构按页面职责分化但共用统一的 spacingradiusshadow interactive feedback</p>
</div>
<div class="bl-grid bl-grid-2 cards-grid">
<TownCard v-for="town in townItems" :key="town.id" :town="town" @click="activeTown = town; townModalOpen = true" />
</div>
<div class="bl-grid bl-grid-2 cards-grid">
<LeaderboardCard v-for="board in leaderboardBoards" :key="board.title" :board="board" />
</div>
<div class="bl-grid bl-grid-4 cards-grid">
<PlayerCard v-for="player in playerItems" :key="player.id" :player="player" @click="activePlayer = player; playerModalOpen = true" />
</div>
<div class="bl-grid bl-grid-2 cards-grid">
<DonationCard v-for="donation in donationItems" :key="`${donation.name}-${donation.time}`" :donation="donation" />
</div>
</section>
<section class="showcase-section">
<div class="bl-section-heading">
<div>
<p class="showcase-kicker">Announcements</p>
<h2 class="bl-section-title">公告时间线与展开卡片</h2>
</div>
<p class="bl-section-copy">保留时间线为专用模式不强行套进设施 / 城镇列表卡布局</p>
</div>
<AnnouncementTimeline :items="announcementItems" />
</section>
<section class="showcase-section">
<div class="bl-section-heading">
<div>
<p class="showcase-kicker">Home</p>
<h2 class="bl-section-title">首页 Bento 特性栅格</h2>
</div>
<p class="bl-section-copy">视觉延续旧首页的功能块式布局但用统一组件和 data-driven 结构输出</p>
</div>
<FeatureBentoGrid :items="bentoItems" />
</section>
<section class="showcase-section">
<div class="bl-section-heading">
<div>
<p class="showcase-kicker">Join</p>
<h2 class="bl-section-title">加入游戏向导</h2>
</div>
<p class="bl-section-copy">保留 Join 页面纵向步骤与选择卡片关系但迁移为可复用状态组件</p>
</div>
<JoinWizard :devices="joinDevices" :playstyles="playstyles" />
</section>
<section class="showcase-section">
<div class="bl-section-heading">
<div>
<p class="showcase-kicker">Detail</p>
<h2 class="bl-section-title">详情弹窗审查入口</h2>
</div>
<p class="bl-section-copy">详情弹窗以 facilities / towns modal 结构为基底stats / sponsor 作为特化实现</p>
</div>
<BaseCard class="modal-launcher">
<div class="button-row">
<BaseButton @click="facilityModalOpen = true">查看设施详情</BaseButton>
<BaseButton variant="secondary" @click="townModalOpen = true">查看城镇详情</BaseButton>
<BaseButton variant="ghost" @click="playerModalOpen = true">查看玩家详情</BaseButton>
<BaseButton variant="soft" @click="sponsorModalOpen = true">查看赞助弹窗</BaseButton>
</div>
</BaseCard>
</section>
</main>
<SiteFooter />
<FacilityDetailModal v-model="facilityModalOpen" :facility="activeFacility" />
<TownDetailModal v-model="townModalOpen" :town="activeTown" />
<PlayerDetailModal v-model="playerModalOpen" :player="activePlayer" />
<SponsorModal v-model="sponsorModalOpen" :summary="sponsorSummary" />
</div>
</template>
<style scoped>
.showcase-page {
min-height: 100vh;
}
.showcase-main {
padding: 40px 0 0;
}
.showcase-section {
margin-top: 36px;
}
.showcase-kicker {
margin: 0 0 10px;
color: var(--bl-accent);
font-size: 0.84rem;
letter-spacing: 0.16em;
text-transform: uppercase;
font-weight: 700;
}
.hero-review-panel {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.button-row,
.badge-row {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.cards-grid {
margin-top: 22px;
}
.modal-launcher {
margin-top: 8px;
}
.showcase-section :deep(h3) {
margin: 0 0 12px;
}
@media (max-width: 840px) {
.showcase-main {
padding-top: 24px;
}
}
</style>
<SiteNavbar :items="navItems" :active-path="activePath" />
<router-view />
<SiteFooter v-if="!isIframePage" />
</template>

View File

@@ -1,4 +1,6 @@
<script setup>
import { RouterLink } from 'vue-router';
defineProps({
open: {
type: Boolean,
@@ -32,18 +34,28 @@ const emit = defineEmits(['close']);
</button>
</div>
<nav class="mobile-drawer__links" aria-label="移动端导航">
<a
v-for="item in items"
:key="item.href"
class="mobile-drawer__link"
:href="item.href"
@click="emit('close')"
>
<span>{{ item.label }}</span>
<small v-if="item.description">{{ item.description }}</small>
</a>
<template v-for="item in items" :key="item.href">
<a
v-if="item.external"
class="mobile-drawer__link"
:href="item.href"
target="_blank"
rel="noopener noreferrer"
@click="emit('close')"
>
<span>{{ item.label }}</span>
</a>
<RouterLink
v-else
class="mobile-drawer__link"
:to="item.href"
@click="emit('close')"
>
<span>{{ item.label }}</span>
</RouterLink>
</template>
</nav>
<a class="mobile-drawer__cta" :href="ctaHref" @click="emit('close')">{{ ctaLabel }}</a>
<RouterLink class="mobile-drawer__cta" :to="ctaHref" @click="emit('close')">{{ ctaLabel }}</RouterLink>
</aside>
</div>
</transition>

View File

@@ -1,4 +1,6 @@
<script setup>
import { RouterLink } from 'vue-router';
const props = defineProps({
brand: {
type: String,
@@ -11,9 +13,9 @@ const props = defineProps({
});
const footerLinks = [
{ label: '文档', href: '/doc.html' },
{ label: '地图', href: '/map.html' },
{ label: '赞助', href: '/sponsor.html' },
{ label: '文档', href: '/doc' },
{ label: '地图', href: '/map' },
{ label: '赞助', href: '/sponsor' },
];
</script>
@@ -25,7 +27,7 @@ const footerLinks = [
<p class="site-footer__copy">© {{ year }} {{ brand }} Minecraft 服务器</p>
</div>
<nav class="site-footer__links" aria-label="页脚导航">
<a v-for="link in footerLinks" :key="link.href" :href="link.href">{{ link.label }}</a>
<RouterLink v-for="link in footerLinks" :key="link.href" :to="link.href">{{ link.label }}</RouterLink>
</nav>
</div>
</footer>

View File

@@ -1,5 +1,6 @@
<script setup>
import { ref } from 'vue';
import { RouterLink } from 'vue-router';
import MobileNavDrawer from './MobileNavDrawer.vue';
const props = defineProps({
@@ -48,22 +49,28 @@ const isActive = (href) => href === props.activePath;
<span></span>
</button>
<a class="site-navbar__logo" href="/">
<RouterLink class="site-navbar__logo" to="/">
<img :src="logoSrc" :alt="logoAlt">
</a>
</RouterLink>
<nav class="site-navbar__links" aria-label="主导航">
<a
v-for="item in items"
:key="item.href"
:href="item.href"
:class="['site-navbar__link', { 'is-active': isActive(item.href) }]"
>
{{ item.label }}
</a>
<template v-for="item in items" :key="item.href">
<a
v-if="item.external"
:href="item.href"
target="_blank"
rel="noopener noreferrer"
class="site-navbar__link"
>{{ item.label }}</a>
<RouterLink
v-else
:to="item.href"
:class="['site-navbar__link', { 'is-active': isActive(item.href) }]"
>{{ item.label }}</RouterLink>
</template>
</nav>
<a class="site-navbar__cta" :href="ctaHref">{{ ctaLabel }}</a>
<RouterLink class="site-navbar__cta" :to="ctaHref">{{ ctaLabel }}</RouterLink>
</div>
</header>

View File

@@ -1,5 +1,6 @@
import { createApp } from 'vue';
import App from './App.vue';
import router from './router';
import './styles.css';
createApp(App).mount('#app');
createApp(App).use(router).mount('#app');

View File

@@ -0,0 +1,507 @@
<script setup>
import { ref, computed, onMounted, onBeforeUnmount, nextTick } from 'vue';
import { useRoute } from 'vue-router';
import FilterPanel from '../components/shared/FilterPanel.vue';
import BaseBadge from '../components/base/BaseBadge.vue';
import EmptyState from '../components/base/EmptyState.vue';
const route = useRoute();
const announcements = ref([]);
const searchQuery = ref('');
const categoryFilter = ref('all');
const expandedId = ref(null);
const editMode = ref(false);
const sharedId = ref(null);
// Secret "edit" keyboard shortcut
let secretBuffer = '';
function onSecretKey(e) {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.isContentEditable) return;
secretBuffer += e.key.toLowerCase();
if (secretBuffer.length > 4) secretBuffer = secretBuffer.slice(-4);
if (secretBuffer === 'edit') {
editMode.value = !editMode.value;
secretBuffer = '';
}
}
onMounted(() => {
document.addEventListener('keydown', onSecretKey);
fetch('/data/announcements.json')
.then(r => r.json())
.then(data => {
data.sort((a, b) => new Date(b.time) - new Date(a.time));
announcements.value = data;
// Expand first item by default
if (data.length > 0) {
expandedId.value = generateAnchorId(data[0]);
}
nextTick(() => handleHash());
});
});
onBeforeUnmount(() => {
document.removeEventListener('keydown', onSecretKey);
});
// Hash-based deep linking
function handleHash() {
const hash = route.hash.replace('#', '');
if (!hash) return;
const match = announcements.value.find(item => generateAnchorId(item) === hash);
if (match) {
expandedId.value = hash;
nextTick(() => {
const el = document.getElementById(hash);
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' });
});
}
}
function generateAnchorId(item) {
const raw = (item.time || '') + '_' + (item.title || '');
let hash = 0;
for (let i = 0; i < raw.length; i++) {
hash = ((hash << 5) - hash) + raw.charCodeAt(i);
hash |= 0;
}
return 'a' + Math.abs(hash).toString(36);
}
const categoryOptions = [
{ value: 'all', label: '全部' },
{ value: 'activity', label: '活动' },
{ value: 'maintenance', label: '维护' },
{ value: 'other', label: '其他' },
];
const categoryLabelMap = { activity: '活动', maintenance: '维护', other: '其他' };
const categoryToneMap = { activity: 'success', maintenance: 'warning', other: 'purple' };
const filtered = computed(() => {
return announcements.value.filter(item => {
const matchCat = categoryFilter.value === 'all' || item.category === categoryFilter.value;
const q = searchQuery.value.toLowerCase().trim();
const matchSearch = !q || item.title.toLowerCase().includes(q) || item.intro.toLowerCase().includes(q);
return matchCat && matchSearch;
});
});
function toggleItem(anchorId) {
expandedId.value = expandedId.value === anchorId ? null : anchorId;
}
function shareItem(item, event) {
event.stopPropagation();
const anchorId = generateAnchorId(item);
const url = location.origin + location.pathname + '#' + anchorId;
navigator.clipboard.writeText(url).then(() => {
sharedId.value = anchorId;
setTimeout(() => { sharedId.value = null; }, 2000);
});
}
function parseBV(input) {
if (!input) return null;
const m = input.trim().match(/(BV[A-Za-z0-9]{10,})/);
return m ? m[1] : null;
}
function onFilterChange({ key, value }) {
if (key === 'category') categoryFilter.value = value;
}
</script>
<template>
<!-- Page Hero -->
<section class="page-hero announcements-hero">
<div class="hero-overlay"></div>
<div class="hero-content">
<h1 class="hero-title">活动公告</h1>
<p class="hero-subtitle">了解服务器最新动态活动安排与维护通知</p>
</div>
</section>
<main class="announcements-container bl-shell">
<!-- Controls -->
<FilterPanel
title="公告列表"
:search-value="searchQuery"
search-placeholder="搜索标题或简介..."
:filters="[
{ key: 'category', label: '分类', options: categoryOptions, modelValue: categoryFilter },
]"
@update:search-value="searchQuery = $event"
@change-filter="onFilterChange"
/>
<!-- Timeline -->
<div v-if="filtered.length" class="timeline">
<div
v-for="(item, index) in filtered"
:key="generateAnchorId(item)"
:id="generateAnchorId(item)"
:class="['timeline-item', `category-${item.category}`]"
>
<div :class="['announcement-card', { expanded: expandedId === generateAnchorId(item) }]">
<!-- Summary -->
<button type="button" class="card-summary" @click="toggleItem(generateAnchorId(item))">
<div class="card-summary-main">
<div class="card-summary-top">
<BaseBadge :tone="categoryToneMap[item.category] || 'neutral'">
{{ categoryLabelMap[item.category] || item.category }}
</BaseBadge>
<h3 class="announcement-title">{{ item.title }}</h3>
</div>
<p class="announcement-intro">{{ item.intro }}</p>
</div>
<span class="card-summary-time">{{ item.time }}</span>
<span class="expand-icon"></span>
</button>
<!-- Detail -->
<div class="card-detail">
<div class="detail-content">
<template v-for="(block, bi) in item.content" :key="bi">
<p v-if="block.type === 'text'">{{ block.content }}</p>
<img v-else-if="block.type === 'image'" :src="block.content" loading="lazy" alt="">
<div v-else-if="block.type === 'video' && parseBV(block.content)" class="video-embed-wrapper">
<iframe
:src="`https://player.bilibili.com/player.html?bvid=${parseBV(block.content)}&autoplay=0&high_quality=1`"
allowfullscreen
sandbox="allow-scripts allow-same-origin allow-popups"
loading="lazy"
></iframe>
</div>
</template>
</div>
<div class="detail-action-btn-row">
<button
type="button"
:class="['btn-share', { shared: sharedId === generateAnchorId(item) }]"
@click="shareItem(item, $event)"
>
{{ sharedId === generateAnchorId(item) ? '✓ 已复制链接' : '🔗 分享' }}
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Empty -->
<EmptyState v-else title="暂无公告" description="当前没有匹配的公告内容。" />
</main>
</template>
<style scoped>
.announcements-hero {
height: 35vh;
min-height: 300px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
text-align: center;
padding-top: var(--bl-header-height);
background: url('https://img.lunadeer.cn/i/2025/11/26/69267755e14e3.png') center/cover no-repeat;
position: relative;
color: #fff;
}
.hero-overlay {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.4);
z-index: 1;
}
.hero-content {
position: relative;
z-index: 2;
}
.hero-title {
font-size: 56px;
font-weight: 700;
letter-spacing: -0.005em;
margin: 0 0 10px;
text-shadow: 0 2px 10px rgba(0, 0, 0, 0.3);
}
.hero-subtitle {
font-size: 28px;
font-weight: 400;
color: rgba(255, 255, 255, 0.9);
margin: 0;
text-shadow: 0 2px 5px rgba(0, 0, 0, 0.3);
}
.announcements-container {
max-width: 900px;
padding: 40px 20px;
}
/* Timeline */
.timeline {
position: relative;
padding-left: 32px;
margin-top: 40px;
}
.timeline::before {
content: '';
position: absolute;
left: 7px;
top: 0;
bottom: 0;
width: 2px;
background: linear-gradient(to bottom, var(--bl-accent), rgba(0, 113, 227, 0.1));
border-radius: 2px;
}
.timeline-item {
position: relative;
margin-bottom: 24px;
}
.timeline-item::before {
content: '';
position: absolute;
left: -32px;
top: 28px;
width: 16px;
height: 16px;
border-radius: 50%;
background: #fff;
border: 3px solid var(--bl-accent);
z-index: 1;
}
.timeline-item.category-activity::before {
border-color: var(--bl-green);
}
.timeline-item.category-maintenance::before {
border-color: var(--bl-warning);
}
.timeline-item.category-other::before {
border-color: var(--bl-purple);
}
/* Announcement Card */
.announcement-card {
background: var(--bl-surface-strong);
border-radius: var(--bl-radius-lg);
box-shadow: var(--bl-shadow-soft);
border: 1px solid rgba(0, 0, 0, 0.03);
overflow: hidden;
transition: var(--bl-transition);
cursor: pointer;
}
.announcement-card:hover {
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.08);
transform: translateY(-2px);
}
.announcement-card.expanded {
cursor: default;
transform: none;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
border-color: rgba(0, 0, 0, 0.06);
}
.card-summary {
width: 100%;
padding: 24px 28px;
display: flex;
align-items: center;
gap: 16px;
background: transparent;
cursor: pointer;
text-align: left;
}
.announcement-card.expanded .card-summary {
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
background: linear-gradient(to bottom, #fff, #fafafa);
}
.card-summary-main {
flex: 1;
min-width: 0;
}
.card-summary-top {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 6px;
flex-wrap: wrap;
}
.announcement-title {
font-size: 18px;
font-weight: 600;
color: var(--bl-text);
margin: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.announcement-card.expanded .announcement-title {
white-space: normal;
overflow: visible;
}
.announcement-intro {
font-size: 14px;
color: var(--bl-text-secondary);
margin: 4px 0 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.announcement-card.expanded .announcement-intro {
white-space: normal;
overflow: visible;
}
.card-summary-time {
font-size: 13px;
color: var(--bl-text-secondary);
white-space: nowrap;
flex-shrink: 0;
}
.expand-icon {
color: var(--bl-text-secondary);
font-size: 14px;
transition: transform 0.3s ease;
flex-shrink: 0;
opacity: 0.4;
}
.announcement-card.expanded .expand-icon {
transform: rotate(180deg);
opacity: 0.6;
}
/* Detail */
.card-detail {
max-height: 0;
overflow: hidden;
transition: max-height 0.45s cubic-bezier(0.25, 1, 0.5, 1), padding 0.35s ease;
padding: 0 28px;
}
.announcement-card.expanded .card-detail {
max-height: 2000px;
padding: 28px 28px 32px;
}
.detail-content {
line-height: 1.8;
font-size: 15px;
color: var(--bl-text);
}
.detail-content p {
margin: 0 0 14px;
}
.detail-content p:last-child {
margin-bottom: 0;
}
.detail-content img {
max-width: 100%;
border-radius: 12px;
margin: 12px 0 16px;
border: 1px solid rgba(0, 0, 0, 0.05);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
}
.video-embed-wrapper {
position: relative;
width: 100%;
padding-bottom: 56.25%;
margin: 12px 0 16px;
border-radius: 12px;
overflow: hidden;
background: #000;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
.video-embed-wrapper iframe {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
border: none;
}
.detail-action-btn-row {
margin-top: 20px;
padding-top: 16px;
border-top: 1px solid rgba(0, 0, 0, 0.05);
display: flex;
justify-content: flex-end;
gap: 10px;
}
.btn-share {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 16px;
background: transparent;
color: var(--bl-text-secondary);
border: 1.5px solid rgba(0, 0, 0, 0.12);
border-radius: 18px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: var(--bl-transition);
}
.btn-share:hover {
color: var(--bl-accent);
border-color: var(--bl-accent);
background: rgba(0, 113, 227, 0.04);
}
@media (max-width: 768px) {
.hero-title {
font-size: 36px;
}
.hero-subtitle {
font-size: 20px;
}
.card-summary {
flex-wrap: wrap;
padding: 18px 20px;
}
.card-summary-time {
width: 100%;
margin-top: 4px;
}
.card-detail {
padding-left: 20px;
padding-right: 20px;
}
.announcement-card.expanded .card-detail {
padding: 20px;
}
}
</style>

19
src/pages/DocPage.vue Normal file
View File

@@ -0,0 +1,19 @@
<template>
<iframe
class="iframe-fullpage"
src="https://schema.lunadeer.cn/public/libraries/wco40gb6blucloqv"
frameborder="0"
allowfullscreen
></iframe>
</template>
<style scoped>
.iframe-fullpage {
position: fixed;
top: var(--bl-header-height);
left: 0;
width: 100%;
height: calc(100vh - var(--bl-header-height));
border: none;
}
</style>

View File

@@ -0,0 +1,536 @@
<script setup>
import { ref, computed, onMounted, nextTick } from 'vue';
import { useRoute } from 'vue-router';
import FilterPanel from '../components/shared/FilterPanel.vue';
import BaseBadge from '../components/base/BaseBadge.vue';
import BaseModal from '../components/base/BaseModal.vue';
import ModalSection from '../components/detail/ModalSection.vue';
import EmptyState from '../components/base/EmptyState.vue';
const route = useRoute();
const facilities = ref([]);
const searchQuery = ref('');
const typeFilter = ref('all');
const dimensionFilter = ref('all');
const modalOpen = ref(false);
const selectedFacility = ref(null);
const sharedId = ref(null);
const editMode = ref(false);
// Secret edit shortcut
let secretBuffer = '';
function onSecretKey(e) {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
secretBuffer += e.key.toLowerCase();
if (secretBuffer.length > 4) secretBuffer = secretBuffer.slice(-4);
if (secretBuffer === 'edit') { editMode.value = !editMode.value; secretBuffer = ''; }
}
onMounted(() => {
document.addEventListener('keydown', onSecretKey);
fetch('/data/facilities.json')
.then(r => r.json())
.then(data => {
facilities.value = data;
nextTick(() => handleHash());
});
});
function handleHash() {
const hash = route.hash.replace('#', '');
if (!hash) return;
const match = facilities.value.find(item => generateId(item) === hash);
if (match) openModal(match);
}
function generateId(item) {
const raw = item.title || '';
let h = 0;
for (let i = 0; i < raw.length; i++) {
h = ((h << 5) - h) + raw.charCodeAt(i);
h |= 0;
}
return 'f' + Math.abs(h).toString(36);
}
const typeOptions = [
{ value: 'all', label: '全部' },
{ value: 'resource', label: '资源' },
{ value: 'xp', label: '经验' },
{ value: 'infrastructure', label: '基建' },
];
const dimensionOptions = [
{ value: 'all', label: '全部' },
{ value: 'overworld', label: '主世界' },
{ value: 'nether', label: '下界' },
{ value: 'end', label: '末地' },
];
const typeTextMap = { resource: '资源', xp: '经验', infrastructure: '基建' };
const dimensionTextMap = { overworld: '主世界', nether: '下界', end: '末地' };
const statusTextMap = { online: '运行中', maintenance: '维护中', offline: '已停用' };
const statusToneMap = { online: 'success', maintenance: 'warning', offline: 'danger' };
const filtered = computed(() => {
return facilities.value.filter(item => {
const matchType = typeFilter.value === 'all' || item.type === typeFilter.value;
const matchDim = dimensionFilter.value === 'all' || item.dimension === dimensionFilter.value;
const q = searchQuery.value.toLowerCase().trim();
const matchSearch = !q || item.title.toLowerCase().includes(q) || item.intro.toLowerCase().includes(q);
return matchType && matchDim && matchSearch;
});
});
function openModal(item) {
selectedFacility.value = item;
modalOpen.value = true;
history.replaceState(null, '', location.pathname + '#' + generateId(item));
}
function closeModal() {
modalOpen.value = false;
selectedFacility.value = null;
history.replaceState(null, '', location.pathname + location.search);
}
function shareItem(item) {
const id = generateId(item);
const url = location.origin + location.pathname + '#' + id;
navigator.clipboard.writeText(url).then(() => {
sharedId.value = id;
setTimeout(() => { sharedId.value = null; }, 2000);
});
}
function getMapUrl(item) {
if (!item.coordinates) return '#';
const c = item.coordinates;
const world = item.dimension === 'nether' ? 'world_nether' : item.dimension === 'end' ? 'world_the_end' : 'world';
return `https://mcmap.lunadeer.cn/#${world}:${c.x}:${c.y}:${c.z}:500:0:0:0:1:flat`;
}
function parseBV(input) {
if (!input) return null;
const m = input.trim().match(/(BV[A-Za-z0-9]{10,})/);
return m ? m[1] : null;
}
function onFilterChange({ key, value }) {
if (key === 'type') typeFilter.value = value;
if (key === 'dimension') dimensionFilter.value = value;
}
</script>
<template>
<!-- Hero -->
<section class="page-hero facilities-hero">
<div class="hero-overlay"></div>
<div class="hero-content">
<h1 class="hero-title">全服共享资源</h1>
<p class="hero-subtitle">共同建设共同分享让生存更轻松</p>
</div>
</section>
<main class="facilities-container bl-shell">
<!-- Controls -->
<FilterPanel
title="设施列表"
:search-value="searchQuery"
search-placeholder="搜索设施名称或简介..."
:filters="[
{ key: 'type', label: '类型', options: typeOptions, modelValue: typeFilter },
{ key: 'dimension', label: '维度', options: dimensionOptions, modelValue: dimensionFilter },
]"
@update:search-value="searchQuery = $event"
@change-filter="onFilterChange"
/>
<!-- Grid -->
<div v-if="filtered.length" class="facilities-grid">
<article
v-for="item in filtered"
:key="generateId(item)"
class="facility-card"
@click="openModal(item)"
>
<div class="card-header">
<h3 class="card-title">{{ item.title }}</h3>
<BaseBadge :tone="statusToneMap[item.status] || 'neutral'">
{{ statusTextMap[item.status] || item.status }}
</BaseBadge>
</div>
<p class="card-intro">{{ item.intro }}</p>
<div class="card-meta">
<span class="meta-tag">{{ typeTextMap[item.type] || item.type }}</span>
<span class="meta-tag">{{ dimensionTextMap[item.dimension] || item.dimension }}</span>
</div>
</article>
</div>
<EmptyState v-else title="暂无设施" description="当前没有匹配的设施信息。" />
<!-- Detail Modal -->
<BaseModal :model-value="modalOpen" width="720px" @update:model-value="closeModal">
<template v-if="selectedFacility" #header>
<div class="modal-header-inner">
<h3>{{ selectedFacility.title }}</h3>
<p class="modal-intro">{{ selectedFacility.intro }}</p>
<div class="modal-badges-row">
<div class="modal-badges">
<BaseBadge :tone="statusToneMap[selectedFacility.status]">
{{ statusTextMap[selectedFacility.status] }}
</BaseBadge>
<BaseBadge tone="accent">
{{ typeTextMap[selectedFacility.type] }}
</BaseBadge>
</div>
<div class="modal-actions">
<button
type="button"
:class="['btn-share', { shared: sharedId === generateId(selectedFacility) }]"
@click="shareItem(selectedFacility)"
>
{{ sharedId === generateId(selectedFacility) ? '✓ 已复制' : '🔗 分享' }}
</button>
</div>
</div>
</div>
</template>
<template v-if="selectedFacility">
<ModalSection title="位置信息">
<p>
{{ dimensionTextMap[selectedFacility.dimension] }}
<template v-if="selectedFacility.coordinates">
· X: {{ selectedFacility.coordinates.x }}, Y: {{ selectedFacility.coordinates.y }}, Z: {{ selectedFacility.coordinates.z }}
</template>
<a
v-if="selectedFacility.coordinates"
:href="getMapUrl(selectedFacility)"
target="_blank"
rel="noopener"
class="map-link"
>
🗺 在地图中查看
</a>
</p>
</ModalSection>
<ModalSection v-if="selectedFacility.contributors?.length" title="贡献 / 维护人员">
<div class="contributors-list">
<span v-for="name in selectedFacility.contributors" :key="name" class="contributor-tag">
<img :src="`https://minotar.net/avatar/${name}/20`" :alt="name" loading="lazy">
{{ name }}
</span>
</div>
</ModalSection>
<ModalSection v-if="selectedFacility.instructions?.length" title="使用说明">
<div class="content-blocks">
<template v-for="(block, bi) in selectedFacility.instructions" :key="bi">
<p v-if="block.type === 'text'">{{ block.content }}</p>
<img v-else-if="block.type === 'image'" :src="block.content" loading="lazy" alt="">
<div v-else-if="block.type === 'video' && parseBV(block.content)" class="video-embed-wrapper">
<iframe
:src="`https://player.bilibili.com/player.html?bvid=${parseBV(block.content)}&autoplay=0&high_quality=1`"
allowfullscreen
sandbox="allow-scripts allow-same-origin allow-popups"
loading="lazy"
></iframe>
</div>
</template>
</div>
</ModalSection>
<ModalSection v-if="selectedFacility.notes?.length" title="注意事项">
<div class="content-blocks">
<template v-for="(block, bi) in selectedFacility.notes" :key="bi">
<p v-if="block.type === 'text'">{{ block.content }}</p>
<img v-else-if="block.type === 'image'" :src="block.content" loading="lazy" alt="">
<div v-else-if="block.type === 'video' && parseBV(block.content)" class="video-embed-wrapper">
<iframe
:src="`https://player.bilibili.com/player.html?bvid=${parseBV(block.content)}&autoplay=0&high_quality=1`"
allowfullscreen
sandbox="allow-scripts allow-same-origin allow-popups"
loading="lazy"
></iframe>
</div>
</template>
</div>
</ModalSection>
</template>
</BaseModal>
</main>
</template>
<style scoped>
.facilities-hero {
height: 35vh;
min-height: 300px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
text-align: center;
padding-top: var(--bl-header-height);
background: url('https://img.lunadeer.cn/i/2025/11/26/69267755e14e3.png') center/cover no-repeat;
position: relative;
color: #fff;
}
.hero-overlay {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.4);
z-index: 1;
}
.hero-content {
position: relative;
z-index: 2;
}
.hero-title {
font-size: 56px;
font-weight: 700;
margin: 0 0 10px;
text-shadow: 0 2px 10px rgba(0, 0, 0, 0.3);
}
.hero-subtitle {
font-size: 28px;
font-weight: 400;
color: rgba(255, 255, 255, 0.9);
margin: 0;
text-shadow: 0 2px 5px rgba(0, 0, 0, 0.3);
}
.facilities-container {
padding: 40px 20px;
}
/* Grid */
.facilities-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 24px;
margin-top: 40px;
}
.facility-card {
background: var(--bl-surface-strong);
border-radius: var(--bl-radius-lg);
padding: 24px;
box-shadow: var(--bl-shadow-soft);
transition: var(--bl-transition);
cursor: pointer;
display: flex;
flex-direction: column;
justify-content: space-between;
border: 1px solid rgba(0, 0, 0, 0.03);
}
.facility-card:hover {
transform: translateY(-4px);
box-shadow: var(--bl-shadow-card);
}
.card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 12px;
}
.card-title {
font-size: 18px;
font-weight: 600;
margin: 0;
flex: 1;
margin-right: 10px;
line-height: 1.3;
}
.card-intro {
font-size: 14px;
color: var(--bl-text-secondary);
margin: 0 0 24px;
line-height: 1.5;
flex-grow: 1;
}
.card-meta {
display: flex;
gap: 8px;
flex-wrap: wrap;
border-top: 1px solid rgba(0, 0, 0, 0.05);
padding-top: 16px;
}
.meta-tag {
font-size: 11px;
background: #f5f5f7;
padding: 4px 10px;
border-radius: 6px;
color: var(--bl-text-secondary);
font-weight: 500;
}
/* Modal Content */
.modal-header-inner h3 {
font-size: 32px;
font-weight: 700;
margin: 0 0 16px;
line-height: 1.2;
}
.modal-intro {
font-size: 18px;
line-height: 1.6;
color: var(--bl-text);
margin: 0 0 20px;
}
.modal-badges-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
flex-wrap: wrap;
}
.modal-badges {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.modal-actions {
display: flex;
gap: 8px;
flex-shrink: 0;
}
.btn-share {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 16px;
background: transparent;
color: var(--bl-text-secondary);
border: 1.5px solid rgba(0, 0, 0, 0.12);
border-radius: 18px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: var(--bl-transition);
}
.btn-share:hover {
color: var(--bl-accent);
border-color: var(--bl-accent);
}
.btn-share.shared {
color: #15803d;
border-color: var(--bl-green);
background: #e8fceb;
}
.map-link {
display: inline-flex;
align-items: center;
gap: 6px;
color: #fff;
background: var(--bl-accent);
padding: 6px 16px;
border-radius: 20px;
text-decoration: none;
font-weight: 500;
font-size: 13px;
margin-left: 12px;
transition: 0.2s;
}
.map-link:hover {
background: var(--bl-accent-strong);
transform: translateY(-1px);
}
.contributors-list {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.contributor-tag {
display: flex;
align-items: center;
background: #fff;
border: 1px solid #eee;
padding: 6px 14px;
border-radius: 30px;
font-size: 14px;
color: var(--bl-text);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.02);
}
.contributor-tag img {
width: 24px;
height: 24px;
border-radius: 50%;
margin-right: 10px;
background: #eee;
}
.content-blocks {
background: #f9f9fa;
padding: 24px;
border-radius: 16px;
border: 1px solid rgba(0, 0, 0, 0.03);
}
.content-blocks p {
font-size: 15px;
margin: 0 0 12px;
line-height: 1.7;
}
.content-blocks p:last-child {
margin-bottom: 0;
}
.content-blocks img {
max-width: 100%;
border-radius: 12px;
margin: 12px 0 20px;
border: 1px solid rgba(0, 0, 0, 0.05);
}
.video-embed-wrapper {
position: relative;
width: 100%;
padding-bottom: 56.25%;
margin: 12px 0 20px;
border-radius: 12px;
overflow: hidden;
background: #000;
}
.video-embed-wrapper iframe {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
border: none;
}
@media (max-width: 768px) {
.hero-title { font-size: 36px; }
.hero-subtitle { font-size: 20px; }
.facilities-grid { grid-template-columns: 1fr; }
.modal-header-inner h3 { font-size: 24px; }
}
</style>

700
src/pages/HomePage.vue Normal file
View File

@@ -0,0 +1,700 @@
<script setup>
import { ref, onMounted, onUnmounted } from 'vue';
// --- Rotating subtitle ---
const SUBTITLES = ['纯净', '原版', '生存', '养老', '休闲'];
const subtitleText = ref(SUBTITLES[0]);
const subtitleFading = ref(false);
let subtitleIdx = 0;
let subtitleTimer = null;
onMounted(() => {
subtitleTimer = setInterval(() => {
subtitleFading.value = true;
setTimeout(() => {
subtitleIdx = (subtitleIdx + 1) % SUBTITLES.length;
subtitleText.value = SUBTITLES[subtitleIdx];
subtitleFading.value = false;
}, 500);
}, 4000);
startRuntime();
fetchServerStatus();
fetchSponsors();
fetchCrowdfunding();
});
onUnmounted(() => {
clearInterval(subtitleTimer);
clearInterval(runtimeTimer);
});
// --- Runtime timer ---
const days = ref(0);
const hours = ref(0);
const minutes = ref(0);
const seconds = ref(0);
let runtimeTimer = null;
function startRuntime() {
const start = new Date('2021-09-14T09:57:59').getTime();
function update() {
const diff = Date.now() - start;
days.value = Math.floor(diff / 86400000);
hours.value = Math.floor((diff % 86400000) / 3600000);
minutes.value = Math.floor((diff % 3600000) / 60000);
seconds.value = Math.floor((diff % 60000) / 1000);
}
update();
runtimeTimer = setInterval(update, 1000);
}
// --- Copy IP ---
const copied = ref(false);
function copyIp() {
navigator.clipboard.writeText('mcpure.lunadeer.cn').then(() => {
copied.value = true;
setTimeout(() => { copied.value = false; }, 2000);
});
}
// --- Server status ---
const onlineText = ref('正在获取状态...');
const isOnline = ref(true);
const playerList = ref([]);
const playersLoading = ref(true);
async function fetchServerStatus() {
try {
const res = await fetch('https://api.mcstatus.io/v2/status/java/mcpure.lunadeer.cn');
const data = await res.json();
if (data.online) {
onlineText.value = `在线人数: ${data.players.online} / ${data.players.max}`;
isOnline.value = true;
playerList.value = data.players.list || [];
} else {
onlineText.value = '服务器离线';
isOnline.value = false;
}
} catch {
onlineText.value = '无法获取状态';
isOnline.value = false;
} finally {
playersLoading.value = false;
}
}
// --- Top sponsors ---
const topSponsors = ref([]);
async function fetchSponsors() {
try {
const res = await fetch('/data/sponsors.txt');
const text = await res.text();
const sponsors = [];
text.trim().split('\n').forEach(line => {
const parts = line.split(',');
if (parts.length < 3) return;
const name = parts[0].trim();
const amount = parseFloat(parts[2].trim().replace('¥', ''));
if (!isNaN(amount)) sponsors.push({ name, amount });
});
const totals = {};
sponsors.forEach(s => { totals[s.name] = (totals[s.name] || 0) + s.amount; });
const sorted = Object.entries(totals)
.map(([name, total]) => ({ name, total }))
.sort((a, b) => b.total - a.total);
topSponsors.value = sorted.slice(0, 3);
} catch { /* silent */ }
}
// --- Crowdfunding ---
const funds = ref([]);
async function fetchCrowdfunding() {
try {
const res = await fetch('/data/fund_progress.txt');
const text = await res.text();
const items = [];
text.trim().split('\n').forEach(line => {
const parts = line.replace(//g, ',').split(',');
if (parts.length >= 3) {
const name = parts[0].trim();
const current = parseFloat(parts[1].trim());
const target = parseFloat(parts[2].trim());
if (name && !isNaN(current) && !isNaN(target) && current > 0) {
items.push({ name, current, target, pct: Math.min(100, (current / target) * 100) });
}
}
});
funds.value = items;
} catch { /* silent */ }
}
// --- Bento features ---
const bentoItems = [
{ key: 'pure', size: 'large', icon: 'fas fa-leaf', title: '纯净原版', desc: '无纷繁复杂的 Mod无破坏平衡的插件。一切简单的就像是单机模式的共享一般', bg: 'https://img.lunadeer.cn/i/2024/02/21/65d592eb4afad.jpg' },
{ key: 'dev', size: 'medium', icon: 'fas fa-code', title: '深度自研', desc: '全栈自研核心,拒绝卡脖子,保证可持续发展', bg: 'https://img.lunadeer.cn/i/2025/11/26/6926982718ba8.png' },
{ key: 'params', size: 'medium', icon: 'fas fa-sliders-h', title: '原汁原味', desc: '生物生成、红石参数与单机高度一致', bg: 'https://img.lunadeer.cn/i/2025/11/26/6926775006dea.jpg' },
{ key: 'land', size: 'small', icon: 'fas fa-home', title: '免费圈地', desc: '2048*2048 超大领地', bg: 'https://img.lunadeer.cn/i/2024/02/21/65d592ea6faa1.jpg' },
{ key: 'bedrock', size: 'small', icon: 'fas fa-mobile-alt', title: '基岩互通', desc: '手机电脑随时畅玩', bg: 'https://img.lunadeer.cn/i/2025/11/26/692677560db46.png' },
{ key: 'hardware', size: 'small', icon: 'fas fa-server', title: '自有硬件', desc: '物理工作站,永不跑路', bg: 'https://img.lunadeer.cn/i/2024/02/21/65d592e248066.jpg' },
{ key: 'fun', size: 'small', icon: 'fas fa-gamepad', title: '娱乐玩法', desc: '空岛、跑酷、小游戏', bg: 'https://img.lunadeer.cn/i/2025/11/26/692677566b07b.png' },
{ key: 'update', size: 'medium', icon: 'fas fa-sync-alt', title: '紧跟新版', desc: '紧跟 Paper 核心版本更新,始终保持在版本前列。第一时间体验 Minecraft 的最新内容', bg: 'https://img.lunadeer.cn/i/2025/11/26/692697b71431b.png' },
{ key: 'guide', size: 'medium', icon: 'fas fa-book-open', title: '新手指南', desc: '完善的服务器文档与活跃的社区,帮助你快速上手,加入白鹿原大家庭', bg: 'https://img.lunadeer.cn/i/2025/11/26/692697b7376c7.png' },
];
const medals = ['🥇', '🥈', '🥉'];
</script>
<template>
<!-- Hero -->
<header class="home-hero">
<div class="hero-overlay"></div>
<div class="hero-content">
<h1 class="hero-title">白鹿原</h1>
<div class="hero-subtitle-container">
<p class="hero-subtitle">
<span>永不换档的</span>
<span :class="['subtitle-dynamic', { 'fade-out': subtitleFading }]">{{ subtitleText }}</span>
<span>Minecraft 服务器</span>
</p>
</div>
<div class="server-runtime">
已稳定运行 <strong>{{ days }}</strong>
<strong>{{ hours }}</strong> 小时
<strong>{{ minutes }}</strong>
<strong>{{ seconds }}</strong>
</div>
<div class="hero-actions">
<div class="server-ip-box" @click="copyIp">
<span>mcpure.lunadeer.cn</span>
<i :class="copied ? 'fas fa-check' : 'fas fa-copy'"></i>
<span v-if="copied" class="copy-toast">已复制!</span>
</div>
<p class="ip-hint">点击复制服务器地址</p>
<div class="online-status-box">
<div class="status-indicator">
<span :class="['status-dot', { offline: !isOnline }]"></span>
<span>{{ onlineText }}</span>
</div>
<div class="players-tooltip">
<template v-if="playersLoading">
<div class="player-item player-item-center">加载中...</div>
</template>
<template v-else-if="playerList.length > 0">
<div v-for="p in playerList" :key="p.uuid" class="player-item">
<img :src="`https://minotar.net/avatar/${p.name_raw}/16`" class="player-avatar" alt="">
<span>{{ p.name_raw }}</span>
</div>
</template>
<template v-else>
<div class="player-item player-item-muted">暂无玩家在线</div>
</template>
</div>
</div>
</div>
</div>
</header>
<main>
<!-- Bento Grid Features -->
<section class="features-section">
<div class="bl-container">
<div class="bento-grid">
<div
v-for="item in bentoItems"
:key="item.key"
:class="['bento-item', `size-${item.size}`]"
:style="{ backgroundImage: `url(${item.bg})` }"
>
<div class="bento-overlay"></div>
<div class="bento-content">
<i :class="item.icon + ' icon'"></i>
<component :is="item.size === 'small' ? 'h4' : 'h3'">{{ item.title }}</component>
<p>{{ item.desc }}</p>
</div>
</div>
</div>
</div>
</section>
<!-- Top Sponsors -->
<section v-if="topSponsors.length > 0" class="sponsors-section">
<div class="bl-container">
<h2 class="section-title">特别鸣谢</h2>
<div class="top-sponsors-grid">
<div v-for="(s, i) in topSponsors" :key="s.name" class="sponsor-card">
<div class="sponsor-rank">{{ medals[i] }}</div>
<div class="sponsor-name">{{ s.name }}</div>
<div class="sponsor-amount">¥{{ s.total.toFixed(2) }}</div>
</div>
</div>
<div class="sponsors-action">
<router-link to="/sponsor" class="view-sponsors-btn">查看赞助列表</router-link>
</div>
</div>
</section>
<!-- Crowdfunding -->
<section v-if="funds.length > 0" class="crowdfunding-section">
<div class="bl-container">
<h2 class="section-title">众筹进度</h2>
<div class="crowdfunding-grid">
<div v-for="fund in funds" :key="fund.name" class="fund-card">
<div class="fund-header">
<div class="fund-title">{{ fund.name }}</div>
<div class="fund-stats">
<span>¥{{ fund.current }}</span> / ¥{{ fund.target }}
</div>
</div>
<div class="progress-bar-bg">
<div class="progress-bar-fill" :style="{ width: fund.pct + '%' }"></div>
</div>
<div class="fund-percentage">{{ fund.pct.toFixed(1) }}%</div>
</div>
</div>
</div>
</section>
</main>
</template>
<style scoped>
/* ====== HERO ====== */
.home-hero {
min-height: 80vh;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
text-align: center;
padding-top: var(--bl-header-height);
background: #000 url('https://img.lunadeer.cn/i/2025/11/26/69267755e14e3.png') center/cover;
position: relative;
color: #fff;
}
.hero-overlay {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.4);
z-index: 1;
}
.hero-content {
position: relative;
z-index: 2;
}
.hero-title {
font-size: 56px;
font-weight: 700;
letter-spacing: -0.005em;
margin: 0 0 10px;
text-shadow: 0 2px 10px rgba(0, 0, 0, 0.3);
}
.hero-subtitle {
font-size: 28px;
font-weight: 400;
color: rgba(255, 255, 255, 0.9);
margin: 0 0 15px;
text-shadow: 0 2px 5px rgba(0, 0, 0, 0.3);
}
.subtitle-dynamic {
display: inline-block;
min-width: 40px;
margin: 0 8px;
transition: opacity 0.5s ease, transform 0.5s ease;
opacity: 1;
transform: translateY(0);
}
.subtitle-dynamic.fade-out {
opacity: 0;
transform: translateY(-10px);
}
.server-runtime {
font-size: 18px;
color: rgba(255, 255, 255, 0.85);
margin-bottom: 40px;
font-weight: 500;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
}
.server-runtime strong {
font-weight: 700;
}
.hero-actions {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
}
.server-ip-box {
background: rgba(255, 255, 255, 0.2);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.3);
color: #fff;
padding: 12px 24px;
border-radius: 980px;
font-size: 17px;
cursor: pointer;
transition: var(--bl-transition);
display: flex;
align-items: center;
gap: 8px;
position: relative;
}
.server-ip-box:hover {
background: rgba(255, 255, 255, 0.3);
transform: scale(1.02);
}
.copy-toast {
position: absolute;
top: -32px;
left: 50%;
transform: translateX(-50%);
background: #333;
color: #fff;
padding: 4px 10px;
border-radius: 6px;
font-size: 12px;
white-space: nowrap;
animation: fadeToast 2s forwards;
}
@keyframes fadeToast {
0%, 80% { opacity: 1; }
100% { opacity: 0; }
}
.ip-hint {
font-size: 12px;
color: rgba(255, 255, 255, 0.7);
margin: 0;
}
/* Online status */
.online-status-box {
margin-top: 15px;
position: relative;
}
.status-indicator {
background: rgba(0, 0, 0, 0.4);
backdrop-filter: blur(10px);
padding: 8px 16px;
border-radius: 20px;
color: rgba(255, 255, 255, 0.9);
font-size: 14px;
display: flex;
align-items: center;
gap: 8px;
}
.status-dot {
width: 8px;
height: 8px;
background: #34c759;
border-radius: 50%;
box-shadow: 0 0 8px rgba(52, 199, 89, 0.6);
}
.status-dot.offline {
background: #ff3b30;
box-shadow: 0 0 8px rgba(255, 59, 48, 0.6);
}
.players-tooltip {
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
margin-top: 10px;
background: rgba(255, 255, 255, 0.95);
color: #1d1d1f;
padding: 10px;
border-radius: 12px;
width: 200px;
max-height: 300px;
overflow-y: auto;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
opacity: 0;
visibility: hidden;
transition: all 0.2s ease;
z-index: 10;
text-align: left;
}
.online-status-box:hover .players-tooltip {
opacity: 1;
visibility: visible;
margin-top: 15px;
}
.players-tooltip::before {
content: '';
position: absolute;
top: -6px;
left: 50%;
transform: translateX(-50%);
border-width: 0 6px 6px 6px;
border-style: solid;
border-color: transparent transparent rgba(255, 255, 255, 0.95) transparent;
}
.player-item {
padding: 6px 8px;
font-size: 13px;
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
display: flex;
align-items: center;
gap: 8px;
}
.player-item:last-child { border-bottom: none; }
.player-item-center { justify-content: center; }
.player-item-muted { justify-content: center; color: #86868b; }
.player-avatar {
width: 16px;
height: 16px;
border-radius: 2px;
}
/* ====== FEATURES BENTO ====== */
.features-section {
padding: 100px 0;
background: var(--bl-bg);
}
.bl-container {
max-width: 1000px;
margin: 0 auto;
padding: 0 20px;
}
.bento-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
grid-auto-rows: 180px;
gap: 20px;
}
.bento-item {
border-radius: var(--bl-radius-lg);
padding: 30px;
display: flex;
flex-direction: column;
justify-content: flex-end;
align-items: flex-start;
text-align: left;
transition: var(--bl-transition);
overflow: hidden;
position: relative;
background-size: cover;
background-position: center;
box-shadow: 2px 4px 12px rgba(0, 0, 0, 0.02);
}
.bento-item:hover {
transform: scale(1.02);
box-shadow: 2px 8px 24px rgba(0, 0, 0, 0.06);
}
.size-large { grid-column: span 2; grid-row: span 2; }
.size-medium { grid-column: span 2; }
.size-small { grid-column: span 1; }
.bento-overlay {
position: absolute;
inset: 0;
background: linear-gradient(to bottom, rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 0.6) 100%);
z-index: 1;
}
.bento-content {
position: relative;
z-index: 2;
width: 100%;
}
.bento-content .icon {
color: #fff;
font-size: 32px;
margin-bottom: 10px;
}
.bento-content h3 {
color: #fff;
font-size: 24px;
font-weight: 700;
margin: 0 0 10px;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
}
.bento-content h4 {
color: #fff;
font-size: 17px;
font-weight: 700;
margin: 10px 0 5px;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
}
.bento-content p {
color: rgba(255, 255, 255, 0.9);
font-size: 14px;
margin: 0;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
}
.size-small p { font-size: 13px; }
/* ====== SPONSORS ====== */
.sponsors-section {
padding: 80px 0;
background: #fff;
text-align: center;
}
.section-title {
font-size: 40px;
font-weight: 700;
text-align: center;
margin: 0 0 60px;
}
.top-sponsors-grid {
display: flex;
justify-content: center;
gap: 30px;
margin-bottom: 40px;
flex-wrap: wrap;
}
.sponsor-card {
background: var(--bl-bg);
border-radius: 16px;
padding: 30px;
width: 250px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
transition: var(--bl-transition);
display: flex;
flex-direction: column;
align-items: center;
}
.sponsor-card:hover {
transform: translateY(-5px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
}
.sponsor-rank { font-size: 48px; margin-bottom: 10px; }
.sponsor-name { font-size: 20px; font-weight: 600; margin-bottom: 5px; }
.sponsor-amount { font-size: 16px; color: var(--bl-accent); font-weight: 500; }
.sponsors-action { text-align: center; }
.view-sponsors-btn {
display: inline-block;
background: #1d1d1f;
color: #fff;
padding: 12px 30px;
border-radius: 980px;
font-size: 16px;
text-decoration: none;
transition: var(--bl-transition);
}
.view-sponsors-btn:hover {
background: #000;
transform: scale(1.05);
}
/* ====== CROWDFUNDING ====== */
.crowdfunding-section {
padding: 80px 0;
background: var(--bl-bg);
}
.crowdfunding-grid {
display: flex;
flex-direction: column;
gap: 30px;
max-width: 800px;
margin: 0 auto;
}
.fund-card {
background: #fff;
border-radius: 16px;
padding: 30px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
transition: var(--bl-transition);
}
.fund-card:hover {
transform: translateY(-5px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
}
.fund-header {
display: flex;
justify-content: space-between;
align-items: flex-end;
margin-bottom: 15px;
}
.fund-title { font-size: 20px; font-weight: 600; }
.fund-stats { font-size: 14px; color: var(--bl-text-secondary); }
.fund-stats span { font-weight: 600; color: var(--bl-text); }
.progress-bar-bg {
width: 100%;
height: 12px;
background: #f0f0f0;
border-radius: 6px;
overflow: hidden;
}
.progress-bar-fill {
height: 100%;
background: linear-gradient(90deg, #0071e3, #34c759);
border-radius: 6px;
transition: width 1s ease-out;
}
.fund-percentage {
text-align: right;
font-size: 12px;
color: var(--bl-text-secondary);
margin-top: 8px;
}
/* ====== RESPONSIVE ====== */
@media (max-width: 900px) {
.bento-grid {
grid-template-columns: 1fr;
grid-auto-rows: auto;
}
.size-large, .size-medium, .size-small {
grid-column: span 1;
grid-row: auto;
min-height: 250px;
}
.hero-title { font-size: 40px; }
.hero-subtitle { font-size: 22px; }
.section-title { font-size: 28px; margin-bottom: 40px; }
}
</style>

990
src/pages/JoinPage.vue Normal file
View File

@@ -0,0 +1,990 @@
<script setup>
import { ref, computed, onMounted } from 'vue';
import { marked } from 'marked';
const currentStep = ref(1);
const totalSteps = 4;
const agreed = ref(false);
const selectedDevice = ref(null);
const selectedEdition = ref('java');
const selectedPlaystyle = ref(null);
const conventionHtml = ref('');
const copiedAddr = ref(null);
onMounted(() => {
fetch('/data/convention.md')
.then(r => r.text())
.then(md => {
conventionHtml.value = marked.parse(md);
})
.catch(() => {
conventionHtml.value = '<p style="color:red">无法加载公约内容</p>';
});
});
// Navigation
function nextStep() {
if (currentStep.value === 2) {
// renderTutorial happens reactively
}
if (currentStep.value < totalSteps) {
currentStep.value++;
window.scrollTo({ top: 0, behavior: 'smooth' });
}
}
function prevStep() {
if (currentStep.value > 1) {
currentStep.value--;
}
}
const canNext = computed(() => {
if (currentStep.value === 1) return agreed.value;
if (currentStep.value === 2) return !!selectedDevice.value;
return true;
});
function selectDevice(d) {
selectedDevice.value = d;
selectedEdition.value = 'java';
}
function copyAddr(text, key) {
navigator.clipboard.writeText(text).then(() => {
copiedAddr.value = key;
setTimeout(() => { copiedAddr.value = null; }, 2000);
});
}
// Device data
const deviceData = {
pc: {
title: '电脑版 (Java Edition)',
recommendations: [
{ name: 'PCL2', icon: 'fas fa-cube', desc: '界面精美功能强大的现代化启动器仅Win', url: 'https://afdian.net/p/0164034c016c11ebafcb52540025c377', primary: true },
{ name: 'HMCL', icon: 'fas fa-horse-head', desc: '历史悠久,跨平台支持好 (Win/Mac/Linux)', url: 'https://hmcl.huangyuhui.net/', primary: false },
],
note: '推荐使用 PCL2 或 HMCL均支持极大改善游戏体验。',
},
ios: {
title: 'iOS 设备',
recommendations: [
{ name: 'PojavLauncher', icon: 'fab fa-app-store-ios', desc: 'iOS 上运行 Java 版的唯一选择', url: 'https://apps.apple.com/us/app/pojavlauncher/id6443526546', primary: true },
],
note: '需要 iOS 14.0 或更高版本。若未越狱,请保持 JIT 开启以获得最佳性能。',
},
android: {
title: '安卓设备',
recommendations: [
{ name: 'FCL 启动器', icon: 'fab fa-android', desc: '基于 FoldCraft 的高性能启动器', url: 'https://github.com/FoldCraftLauncher/FoldCraftLauncher/releases', primary: true },
{ name: 'PojavLauncher', icon: 'fas fa-gamepad', desc: '经典的移动端 Java 版启动器', url: 'https://play.google.com/store/apps/details?id=net.kdt.pojavlaunch', primary: false },
],
note: '建议设备拥有至少 4GB 运存以流畅运行 1.21 版本。',
},
};
const bedrockDeviceData = {
pc: {
title: '电脑版 (Bedrock Edition)',
recommendations: [
{ name: 'Minecraft 基岩版', icon: 'fas fa-cube', desc: '从 Microsoft Store 获取 Minecraft需 Windows 10/11', url: 'https://www.xbox.com/games/store/minecraft/9NBLGGH2JHXJ', primary: true },
],
note: '基岩版通过 Microsoft Store 购买,使用 Xbox / Microsoft 账号登录即可游玩。',
},
ios: {
title: 'iOS 基岩版',
recommendations: [
{ name: 'Minecraft', icon: 'fas fa-cube', desc: '从 App Store 购买并下载 Minecraft', url: 'https://apps.apple.com/app/minecraft/id479516143', primary: true },
],
note: '基岩版是 iOS 上的原生 Minecraft性能最佳、操作体验最好。',
},
android: {
title: '安卓基岩版',
recommendations: [
{ name: 'Minecraft', icon: 'fas fa-cube', desc: '从 Google Play 购买并下载 Minecraft', url: 'https://play.google.com/store/apps/details?id=com.mojang.minecraftpe', primary: true },
],
note: '基岩版是安卓上的原生 Minecraft性能最佳、操作体验最好。',
},
};
const currentDeviceData = computed(() => {
if (!selectedDevice.value) return null;
const source = selectedEdition.value === 'bedrock' ? bedrockDeviceData : deviceData;
return source[selectedDevice.value] || null;
});
// Tutorial data
const deviceTutorials = {
pc: [
{ title: '登录账号', desc: '打开启动器PCL2/HMCL选择"添加账号"。推荐使用 Microsoft 账号登录拥有正版 Minecraft 的账户。' },
{ title: '安装游戏', desc: '在启动器中创建一个新游戏配置,选择游戏版本 <strong>1.21.x</strong>。强烈建议安装 <a href="https://fabricmc.net/" target="_blank">Fabric</a> 加载器以获得更好的模组支持和性能优化。' },
{ title: '启动游戏', desc: '等待游戏资源文件下载完成,点击启动游戏直到看到 Minecraft 主界面。' },
{ title: '加入服务器', desc: '点击"多人游戏" → "添加服务器"', serverAddr: 'mcpure.lunadeer.cn' },
],
ios: [
{ title: '准备环境', desc: '打开 PojavLauncher。若您的设备未越狱请确保已启用 JITJust-In-Time以获得可玩的帧率。' },
{ title: '登录账号', desc: '点击"添加账户",选择"Microsoft 账户"并完成登录流程。' },
{ title: '下载并启动', desc: '点击"创建新配置",选择版本 <strong>1.21.x</strong>。建议调整内存分配至设备总内存的 50% 左右,然后点击"启动"。' },
{ title: '加入服务器', desc: '进入主界面后,选择 Multiplayer → Add Server', serverAddr: 'mcpure.lunadeer.cn' },
],
android: [
{ title: '配置启动器', desc: '打开 FCL 或 PojavLauncher。给予必要的存储权限。' },
{ title: '登录账号', desc: '在账户设置中添加 Microsoft 账户。' },
{ title: '安装版本', desc: '下载 <strong>1.21.x</strong> 游戏核心。FCL 用户可直接使用内置下载源加速下载。建议安装 OptiFine 或 Fabric+Sodium 以提升帧率。' },
{ title: '加入服务器', desc: '启动游戏后,点击 Multiplayer → Add Server', serverAddr: 'mcpure.lunadeer.cn' },
],
};
const bedrockTutorials = {
pc: [
{ title: '获取游戏', desc: '从 <a href="https://www.xbox.com/games/store/minecraft/9NBLGGH2JHXJ" target="_blank">Microsoft Store</a> 购买并下载 Minecraft基岩版/Bedrock Edition需要 Windows 10 或 Windows 11。' },
{ title: '登录账号', desc: '打开 Minecraft使用 Microsoft / Xbox 账号登录。' },
{ title: '加入服务器', desc: '点击"游戏" → 切换到"服务器"标签页 → 滚动到底部点击"添加服务器"', serverAddr: 'mcbe.lunadeer.cn', serverPort: '15337' },
],
ios: [
{ title: '获取游戏', desc: '从 <a href="https://apps.apple.com/app/minecraft/id479516143" target="_blank">App Store</a> 购买并下载 Minecraft。' },
{ title: '登录账号', desc: '打开 Minecraft使用 Microsoft / Xbox 账号登录。' },
{ title: '加入服务器', desc: '点击"游戏" → 切换到"服务器"标签页 → 滚动到底部点击"添加服务器"', serverAddr: 'mcbe.lunadeer.cn', serverPort: '15337' },
],
android: [
{ title: '获取游戏', desc: '从 <a href="https://play.google.com/store/apps/details?id=com.mojang.minecraftpe" target="_blank">Google Play</a> 购买并下载 Minecraft。' },
{ title: '登录账号', desc: '打开 Minecraft使用 Microsoft / Xbox 账号登录。' },
{ title: '加入服务器', desc: '点击"游戏" → 切换到"服务器"标签页 → 滚动到底部点击"添加服务器"', serverAddr: 'mcbe.lunadeer.cn', serverPort: '15337' },
],
};
const currentTutorial = computed(() => {
const d = selectedDevice.value || 'pc';
const source = selectedEdition.value === 'bedrock' ? bedrockTutorials : deviceTutorials;
return source[d] || source['pc'];
});
// Playstyle data
const playstyleData = {
'large-town': {
title: '融入大型城镇', subtitle: '快速启航,共建繁华 (10+人)', icon: 'fas fa-city',
target: '希望跳过艰难的初期积累,快速投入大规模建造与合作的玩家。',
pros: ['资源无忧:可直接从公共仓库获取建材与工具。', '工业完善:享受成熟的自动化生产带来的便利。'],
cons: ['为了整体美观与规划,可能需要遵守城镇的建筑风格与管理安排,自由度相对受限。'],
},
'small-town': {
title: '加入小型城镇', subtitle: '共同成长,见证历史 (3-10人)', icon: 'fas fa-home',
target: '喜欢参与从零到一的建设过程,享受亲手打造家园成就感的玩家。',
pros: ['发展参与感:亲身参与城镇的规划与扩张。', '自由度较高:在发展初期通常有更多的个人发挥空间。'],
cons: ['初期资源相对有限,需要与同伴共同努力。'],
},
friends: {
title: '与朋友共建家园', subtitle: '白手起家,开创时代 (1-3人)', icon: 'fas fa-user-friends',
target: '拥有固定小团体,渴望一片完全属于自己的领地的玩家。',
pros: ['绝对自由:从选址到规划,一切由你定义。', '纯粹体验:体验最原始的协作与创造乐趣。'],
cons: ['这是一条充满挑战的道路,但从无到有建立的一切都将格外珍贵。'],
},
solo: {
title: '独狼求生', subtitle: '自力更生,隐于山林', icon: 'fas fa-hiking',
target: '享受孤独,崇尚一切亲力亲为的硬核生存玩家。',
pros: ['极致的自由与独立,你的世界只属于你。', '可尝试与其他玩家进行贸易换取无法独自获得的资源。'],
cons: ['一切都需要亲力亲为,生存挑战较大。'],
},
};
const playstyleKeys = Object.keys(playstyleData);
const selectedPlaystyleData = computed(() =>
selectedPlaystyle.value ? playstyleData[selectedPlaystyle.value] : null
);
const stepLabels = ['阅读公约', '选择设备', '加入教程', '游戏风格'];
</script>
<template>
<section class="join-header">
<h1>加入白鹿原</h1>
<p>跟随引导几分钟内即可开始你的冒险</p>
</section>
<main class="join-container bl-shell">
<div class="wizard-layout">
<!-- Sidebar progress -->
<aside class="wizard-sidebar">
<div class="progress-steps">
<div
v-for="(label, idx) in stepLabels"
:key="idx"
class="progress-step"
:class="{
active: currentStep === idx + 1,
completed: currentStep > idx + 1,
}"
>
<span class="step-num">{{ idx + 1 }}</span>
<span class="step-label">{{ label }}</span>
</div>
</div>
<div class="progress-bar-track">
<div class="progress-fill" :style="{ width: ((currentStep - 1) / (totalSteps - 1)) * 100 + '%' }"></div>
</div>
</aside>
<!-- Content -->
<div class="wizard-content">
<!-- Step 1: Convention -->
<div v-if="currentStep === 1" class="step-content">
<h2>📜 服务器公约</h2>
<p class="step-desc">请仔细阅读以下公约内容确认后即可继续</p>
<div class="convention-box" v-html="conventionHtml"></div>
<label class="agree-label">
<input v-model="agreed" type="checkbox">
<span>我已阅读并同意遵守以上公约</span>
</label>
</div>
<!-- Step 2: Device -->
<div v-if="currentStep === 2" class="step-content">
<h2>📱 选择你的设备</h2>
<p class="step-desc">我们将根据您的设备提供专属的入服指导</p>
<div class="device-cards">
<div
v-for="d in ['pc', 'ios', 'android']"
:key="d"
:class="['device-card', { selected: selectedDevice === d }]"
@click="selectDevice(d)"
>
<i :class="d === 'pc' ? 'fas fa-desktop' : d === 'ios' ? 'fab fa-apple' : 'fab fa-android'"></i>
<span>{{ d === 'pc' ? '电脑' : d === 'ios' ? 'iOS' : '安卓' }}</span>
</div>
</div>
<!-- Edition selector -->
<div v-if="selectedDevice" class="edition-selector">
<button
:class="['edition-btn', { active: selectedEdition === 'java' }]"
@click="selectedEdition = 'java'"
>Java版</button>
<button
:class="['edition-btn', { active: selectedEdition === 'bedrock' }]"
@click="selectedEdition = 'bedrock'"
>基岩版</button>
</div>
<!-- Launcher recommendation -->
<div v-if="currentDeviceData" class="recommendation-section">
<div class="recommendation-header">
<h3> {{ currentDeviceData.title }} 准备启动器</h3>
<p>{{ currentDeviceData.note }}</p>
</div>
<div class="launcher-grid">
<a
v-for="req in currentDeviceData.recommendations"
:key="req.name"
:href="req.url"
target="_blank"
rel="noopener"
:class="['launcher-card', { primary: req.primary }]"
>
<div class="launcher-icon"><i :class="req.icon"></i></div>
<div class="launcher-details">
<h4>{{ req.name }} <span v-if="req.primary" class="badge-rec">推荐</span></h4>
<p>{{ req.desc }}</p>
</div>
<div class="launcher-action"><i class="fas fa-download"></i></div>
</a>
</div>
</div>
</div>
<!-- Step 3: Tutorial -->
<div v-if="currentStep === 3" class="step-content">
<h2>🎮 加入教程</h2>
<p class="step-desc">按照以下步骤操作即可进入白鹿原服务器</p>
<div class="tutorial-steps">
<div v-for="(step, idx) in currentTutorial" :key="idx" class="tutorial-step">
<div class="step-badge">{{ idx + 1 }}</div>
<div class="step-text">
<h4>{{ step.title }}</h4>
<p v-html="step.desc"></p>
<template v-if="step.serverAddr">
<div class="server-address-box">
<span v-if="step.serverPort">地址</span>
<code>{{ step.serverAddr }}</code>
<button class="btn-copy" @click="copyAddr(step.serverAddr, 'addr-' + idx)">
<template v-if="copiedAddr === 'addr-' + idx"><i class="fas fa-check"></i> 已复制</template>
<template v-else><i class="fas fa-copy"></i> 复制</template>
</button>
</div>
<div v-if="step.serverPort" class="server-address-box">
<span>端口</span>
<code>{{ step.serverPort }}</code>
<button class="btn-copy" @click="copyAddr(step.serverPort, 'port-' + idx)">
<template v-if="copiedAddr === 'port-' + idx"><i class="fas fa-check"></i> 已复制</template>
<template v-else><i class="fas fa-copy"></i> 复制</template>
</button>
</div>
</template>
</div>
</div>
</div>
</div>
<!-- Step 4: Playstyle -->
<div v-if="currentStep === 4" class="step-content">
<h2>🌟 选择游戏风格</h2>
<p class="step-desc">了解不同的游戏方式找到最适合你的冒险之旅</p>
<div class="playstyle-cards">
<div
v-for="key in playstyleKeys"
:key="key"
:class="['playstyle-card', { selected: selectedPlaystyle === key }]"
@click="selectedPlaystyle = key"
>
<i :class="playstyleData[key].icon"></i>
<h4>{{ playstyleData[key].title }}</h4>
<p>{{ playstyleData[key].subtitle }}</p>
</div>
</div>
<div v-if="selectedPlaystyleData" class="playstyle-details">
<h3>{{ selectedPlaystyleData.title }}</h3>
<p class="detail-subtitle">{{ selectedPlaystyleData.subtitle }}</p>
<p class="detail-target"><strong>适合</strong>{{ selectedPlaystyleData.target }}</p>
<div class="pros-cons">
<div class="pros">
<h4><i class="fas fa-check-circle"></i> 优势</h4>
<ul>
<li v-for="p in selectedPlaystyleData.pros" :key="p">{{ p }}</li>
</ul>
</div>
<div class="cons">
<h4><i class="fas fa-exclamation-circle"></i> 注意</h4>
<ul>
<li v-for="c in selectedPlaystyleData.cons" :key="c">{{ c }}</li>
</ul>
</div>
</div>
</div>
</div>
<!-- Wizard Actions -->
<div class="wizard-actions">
<button class="wizard-btn secondary" :disabled="currentStep === 1" @click="prevStep">
<i class="fas fa-arrow-left"></i> 上一步
</button>
<button
v-if="currentStep < totalSteps"
class="wizard-btn primary"
:disabled="!canNext"
@click="nextStep"
>
下一步 <i class="fas fa-arrow-right"></i>
</button>
<template v-if="currentStep === totalSteps">
<router-link to="/" class="wizard-btn primary">
<i class="fas fa-home"></i> 返回首页
</router-link>
</template>
</div>
</div>
</div>
</main>
</template>
<style scoped>
/* Header */
.join-header {
padding: calc(var(--bl-header-height) + 48px) 20px 48px;
text-align: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #fff;
}
.join-header h1 {
font-size: 48px;
font-weight: 700;
margin: 0 0 12px;
}
.join-header p {
font-size: 20px;
opacity: 0.9;
margin: 0;
}
.join-container {
padding: 40px 20px;
}
.wizard-layout {
display: flex;
gap: 40px;
align-items: flex-start;
}
/* Sidebar */
.wizard-sidebar {
flex-shrink: 0;
width: 200px;
position: sticky;
top: calc(var(--bl-header-height) + 20px);
}
.progress-steps {
display: flex;
flex-direction: column;
gap: 4px;
}
.progress-step {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
border-radius: 12px;
transition: var(--bl-transition);
font-size: 14px;
color: var(--bl-text-secondary);
}
.progress-step.active {
background: var(--bl-accent);
color: #fff;
font-weight: 600;
}
.progress-step.completed {
color: var(--bl-accent);
}
.step-num {
width: 28px;
height: 28px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
font-weight: 700;
background: #f0f0f2;
flex-shrink: 0;
}
.progress-step.active .step-num {
background: rgba(255, 255, 255, 0.3);
color: #fff;
}
.progress-step.completed .step-num {
background: var(--bl-accent);
color: #fff;
}
.progress-bar-track {
height: 4px;
background: #e5e5ea;
border-radius: 2px;
margin-top: 16px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: var(--bl-accent);
border-radius: 2px;
transition: width 0.5s ease;
}
/* Content */
.wizard-content {
flex: 1;
min-width: 0;
}
.step-content h2 {
font-size: 28px;
font-weight: 700;
margin: 0 0 8px;
}
.step-desc {
font-size: 16px;
color: var(--bl-text-secondary);
margin: 0 0 24px;
}
/* Convention */
.convention-box {
background: var(--bl-surface-strong);
border: 1px solid rgba(0, 0, 0, 0.06);
border-radius: var(--bl-radius-lg);
padding: 32px;
max-height: 400px;
overflow-y: auto;
margin-bottom: 20px;
font-size: 15px;
line-height: 1.8;
}
.convention-box :deep(h1),
.convention-box :deep(h2),
.convention-box :deep(h3) {
margin-top: 20px;
margin-bottom: 10px;
}
.convention-box :deep(p) {
margin: 0 0 12px;
}
.agree-label {
display: flex;
align-items: center;
gap: 10px;
font-size: 15px;
font-weight: 600;
cursor: pointer;
user-select: none;
}
.agree-label input {
width: 18px;
height: 18px;
accent-color: var(--bl-accent);
}
/* Device cards */
.device-cards {
display: flex;
gap: 16px;
margin-bottom: 24px;
flex-wrap: wrap;
}
.device-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 28px 36px;
border: 2px solid rgba(0, 0, 0, 0.08);
border-radius: var(--bl-radius-lg);
cursor: pointer;
transition: var(--bl-transition);
background: var(--bl-surface-strong);
font-weight: 600;
font-size: 15px;
}
.device-card i {
font-size: 32px;
color: var(--bl-text-secondary);
}
.device-card:hover {
border-color: var(--bl-accent);
}
.device-card.selected {
border-color: var(--bl-accent);
background: rgba(99, 102, 241, 0.05);
}
.device-card.selected i {
color: var(--bl-accent);
}
/* Edition selector */
.edition-selector {
display: flex;
gap: 8px;
margin-bottom: 24px;
}
.edition-btn {
padding: 8px 20px;
border-radius: 20px;
border: 1.5px solid rgba(0, 0, 0, 0.12);
background: transparent;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: var(--bl-transition);
font-family: inherit;
color: var(--bl-text-secondary);
}
.edition-btn.active {
background: var(--bl-accent);
color: #fff;
border-color: var(--bl-accent);
}
/* Launcher recommendation */
.recommendation-section {
background: var(--bl-surface-strong);
border-radius: var(--bl-radius-lg);
padding: 24px;
border: 1px solid rgba(0, 0, 0, 0.05);
}
.recommendation-header h3 {
font-size: 18px;
font-weight: 700;
margin: 0 0 8px;
}
.recommendation-header p {
font-size: 14px;
color: var(--bl-text-secondary);
margin: 0 0 20px;
}
.launcher-grid {
display: flex;
flex-direction: column;
gap: 12px;
}
.launcher-card {
display: flex;
align-items: center;
gap: 16px;
padding: 16px 20px;
border-radius: 14px;
border: 1.5px solid rgba(0, 0, 0, 0.06);
text-decoration: none;
color: var(--bl-text);
transition: var(--bl-transition);
background: #fff;
}
.launcher-card.primary {
border-color: var(--bl-accent);
background: rgba(99, 102, 241, 0.03);
}
.launcher-card:hover {
transform: translateY(-2px);
box-shadow: var(--bl-shadow-card);
}
.launcher-icon {
width: 44px;
height: 44px;
border-radius: 12px;
background: #f5f5f7;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
color: var(--bl-accent);
flex-shrink: 0;
}
.launcher-details {
flex: 1;
}
.launcher-details h4 {
font-size: 16px;
font-weight: 700;
margin: 0 0 4px;
}
.launcher-details p {
font-size: 13px;
color: var(--bl-text-secondary);
margin: 0;
}
.badge-rec {
display: inline-block;
background: var(--bl-accent);
color: #fff;
padding: 2px 8px;
border-radius: 8px;
font-size: 11px;
font-weight: 600;
margin-left: 6px;
vertical-align: middle;
}
.launcher-action {
font-size: 16px;
color: var(--bl-text-secondary);
}
/* Tutorial */
.tutorial-steps {
display: flex;
flex-direction: column;
gap: 20px;
}
.tutorial-step {
display: flex;
gap: 16px;
align-items: flex-start;
}
.step-badge {
width: 32px;
height: 32px;
border-radius: 50%;
background: var(--bl-accent);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 14px;
flex-shrink: 0;
margin-top: 2px;
}
.step-text {
flex: 1;
}
.step-text h4 {
font-size: 17px;
font-weight: 700;
margin: 0 0 8px;
}
.step-text p {
font-size: 15px;
line-height: 1.6;
margin: 0 0 12px;
color: var(--bl-text-secondary);
}
.step-text :deep(a) {
color: var(--bl-accent);
text-decoration: underline;
}
.server-address-box {
display: flex;
align-items: center;
gap: 10px;
background: #f5f5f7;
padding: 10px 16px;
border-radius: 10px;
margin-bottom: 8px;
}
.server-address-box span {
font-size: 13px;
color: var(--bl-text-secondary);
flex-shrink: 0;
}
.server-address-box code {
font-family: 'Inter', monospace;
font-size: 15px;
font-weight: 700;
flex: 1;
color: var(--bl-text);
}
.btn-copy {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 6px 14px;
background: var(--bl-accent);
color: #fff;
border: none;
border-radius: 8px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: var(--bl-transition);
font-family: inherit;
white-space: nowrap;
}
.btn-copy:hover {
background: var(--bl-accent-strong);
}
/* Playstyle */
.playstyle-cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.playstyle-card {
text-align: center;
padding: 28px 16px;
border: 2px solid rgba(0, 0, 0, 0.06);
border-radius: var(--bl-radius-lg);
cursor: pointer;
transition: var(--bl-transition);
background: var(--bl-surface-strong);
}
.playstyle-card:hover {
border-color: var(--bl-accent);
}
.playstyle-card.selected {
border-color: var(--bl-accent);
background: rgba(99, 102, 241, 0.05);
}
.playstyle-card i {
font-size: 32px;
color: var(--bl-accent);
margin-bottom: 12px;
}
.playstyle-card h4 {
font-size: 16px;
font-weight: 700;
margin: 0 0 6px;
}
.playstyle-card p {
font-size: 13px;
color: var(--bl-text-secondary);
margin: 0;
}
.playstyle-details {
background: var(--bl-surface-strong);
border-radius: var(--bl-radius-lg);
padding: 28px;
border: 1px solid rgba(0, 0, 0, 0.05);
animation: fadeIn 0.3s ease;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
.playstyle-details h3 {
font-size: 22px;
font-weight: 700;
margin: 0 0 4px;
}
.detail-subtitle {
font-size: 14px;
color: var(--bl-text-secondary);
margin: 0 0 16px;
}
.detail-target {
font-size: 15px;
margin: 0 0 20px;
line-height: 1.5;
}
.pros-cons {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
}
.pros h4 { color: #16a34a; }
.cons h4 { color: #dc2626; }
.pros h4 i,
.cons h4 i {
margin-right: 6px;
}
.pros ul,
.cons ul {
padding-left: 20px;
margin: 8px 0 0;
}
.pros li,
.cons li {
font-size: 14px;
line-height: 1.6;
margin-bottom: 6px;
}
/* Wizard actions */
.wizard-actions {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 40px;
padding-top: 20px;
border-top: 1px solid rgba(0, 0, 0, 0.06);
}
.wizard-btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 12px 28px;
border-radius: 14px;
font-size: 15px;
font-weight: 600;
cursor: pointer;
transition: var(--bl-transition);
border: none;
text-decoration: none;
font-family: inherit;
}
.wizard-btn.primary {
background: var(--bl-accent);
color: #fff;
}
.wizard-btn.primary:hover:not(:disabled) {
background: var(--bl-accent-strong);
transform: translateY(-1px);
}
.wizard-btn.secondary {
background: transparent;
color: var(--bl-text-secondary);
border: 1.5px solid rgba(0, 0, 0, 0.1);
}
.wizard-btn.secondary:hover:not(:disabled) {
border-color: var(--bl-accent);
color: var(--bl-accent);
}
.wizard-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* Responsive */
@media (max-width: 800px) {
.wizard-layout {
flex-direction: column;
gap: 24px;
}
.wizard-sidebar {
width: 100%;
position: static;
}
.progress-steps {
flex-direction: row;
overflow-x: auto;
gap: 2px;
}
.step-label {
display: none;
}
.join-header h1 { font-size: 32px; }
.device-cards { flex-direction: column; }
.pros-cons { grid-template-columns: 1fr; }
.playstyle-cards { grid-template-columns: 1fr 1fr; }
}
</style>

19
src/pages/MapPage.vue Normal file
View File

@@ -0,0 +1,19 @@
<template>
<iframe
class="iframe-fullpage"
src="https://mcmap.lunadeer.cn/"
frameborder="0"
allowfullscreen
></iframe>
</template>
<style scoped>
.iframe-fullpage {
position: fixed;
top: var(--bl-header-height);
left: 0;
width: 100%;
height: calc(100vh - var(--bl-header-height));
border: none;
}
</style>

19
src/pages/PhotoPage.vue Normal file
View File

@@ -0,0 +1,19 @@
<template>
<iframe
class="iframe-fullpage"
src="https://mcphoto.lunadeer.cn/"
frameborder="0"
allowfullscreen
></iframe>
</template>
<style scoped>
.iframe-fullpage {
position: fixed;
top: var(--bl-header-height);
left: 0;
width: 100%;
height: calc(100vh - var(--bl-header-height));
border: none;
}
</style>

512
src/pages/SponsorPage.vue Normal file
View File

@@ -0,0 +1,512 @@
<script setup>
import { ref, computed, onMounted } from 'vue';
import BaseModal from '../components/base/BaseModal.vue';
import EmptyState from '../components/base/EmptyState.vue';
const sponsors = ref([]);
const grandTotal = ref(0);
const animatedTotal = ref(0);
const searchQuery = ref('');
const projectFilter = ref('all');
const modalOpen = ref(false);
const isMobile = ref(false);
onMounted(() => {
isMobile.value = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent) || window.innerWidth < 768;
fetch('/data/sponsors.txt')
.then(r => r.text())
.then(text => {
const parsed = parseSponsors(text);
let total = 0;
parsed.forEach(item => { total += item.amount; });
grandTotal.value = total;
sponsors.value = [...parsed].reverse(); // newest first
animateTotal(total);
});
});
function parseSponsors(text) {
if (!text) return [];
const result = [];
text.trim().split('\n').forEach(line => {
const parts = line.split(',');
if (parts.length < 3) return;
const name = parts[0].trim();
const project = parts[1].trim();
const amount = parseFloat(parts[2].trim().replace('¥', ''));
const date = parts[3] ? parts[3].trim() : '';
if (!isNaN(amount)) result.push({ name, project, amount, date });
});
return result;
}
function animateTotal(end) {
const duration = 2000;
let start = null;
function step(ts) {
if (!start) start = ts;
const progress = Math.min((ts - start) / duration, 1);
const ease = 1 - Math.pow(1 - progress, 4); // easeOutQuart
animatedTotal.value = Math.floor(ease * end);
if (progress < 1) requestAnimationFrame(step);
else animatedTotal.value = end;
}
requestAnimationFrame(step);
}
// Unique projects for filters
const projectOptions = computed(() => {
const set = new Set();
sponsors.value.forEach(s => { if (s.project) set.add(s.project); });
return Array.from(set);
});
const filtered = computed(() => {
return sponsors.value.filter(item => {
const matchProject = projectFilter.value === 'all' || item.project === projectFilter.value;
const q = searchQuery.value.toLowerCase().trim();
const matchSearch = !q || item.name.toLowerCase().includes(q);
return matchProject && matchSearch;
});
});
function formatAmount(n) {
return '¥' + n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
function setProject(p) {
projectFilter.value = p;
}
</script>
<template>
<!-- Hero -->
<section class="sponsor-hero">
<h1>感谢每一位支持者</h1>
<div class="total-donations">
<span class="counter-label">累计获得赞助</span>
<span class="counter-value">¥{{ animatedTotal.toLocaleString('en-US') }}</span>
</div>
<p class="hero-subtitle">因为有你们白鹿原才能走得更远</p>
</section>
<main class="sponsor-container bl-shell">
<!-- Controls -->
<div class="controls-section">
<h2 class="section-title sponsor-list-title"> 赞助列表</h2>
<div class="controls-header">
<div class="search-box">
<i class="fas fa-search"></i>
<input
v-model="searchQuery"
type="text"
placeholder="搜索赞助者姓名..."
>
</div>
<button class="cta-button outline" @click="modalOpen = true">
<i class="fas fa-heart"></i> 我要支持
</button>
</div>
<div class="filter-tags">
<button
:class="['filter-tag', { active: projectFilter === 'all' }]"
@click="setProject('all')"
>全部</button>
<button
v-for="proj in projectOptions"
:key="proj"
:class="['filter-tag', { active: projectFilter === proj }]"
@click="setProject(proj)"
>{{ proj }}</button>
</div>
</div>
<!-- Donation Grid -->
<div v-if="filtered.length" class="donation-grid">
<div
v-for="(item, idx) in filtered"
:key="idx"
class="donation-card"
:style="{ animationDelay: Math.min(idx * 0.05, 1) + 's' }"
>
<div class="donation-header">
<div class="donor-info">
<img
:src="`https://minotar.net/helm/${item.name}/64.png`"
class="mini-avatar"
:alt="item.name"
loading="lazy"
@error="($event.target).src = 'https://minotar.net/helm/MHF_Steve/64.png'"
>
<div class="donor-name">{{ item.name }}</div>
</div>
<div class="donation-amount">¥{{ item.amount }}</div>
</div>
<div class="donation-card-body">
<div class="donation-purpose">{{ item.project }}</div>
<div class="donation-date">
<i class="far fa-clock donation-date-icon"></i>{{ item.date }}
</div>
</div>
</div>
</div>
<EmptyState v-else title="暂无记录" description="没有找到匹配的赞助记录。" />
<!-- Sponsor Modal -->
<BaseModal :model-value="modalOpen" width="480px" @update:model-value="modalOpen = $event">
<template #header>
<div class="modal-gift-icon">
<i class="fas fa-gift"></i>
</div>
<h3 class="modal-title">支持白鹿原服务器</h3>
<p class="modal-subtitle">您的每一次支持都将帮助我们提升服务器性能维持更长久的运营</p>
</template>
<!-- Desktop QR -->
<div v-if="!isMobile" class="desktop-qr-view">
<div class="qr-placeholder">
<img
src="https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=https%3A%2F%2Fqr.alipay.com%2F2cz0344fnaulnbybhp04"
alt="支付宝二维码"
class="qr-img"
>
</div>
<p class="desktop-qr-hint">推荐使用支付宝扫码</p>
</div>
<!-- Mobile Button -->
<div v-else class="mobile-btn-view">
<a
href="https://qr.alipay.com/2cz0344fnaulnbybhp04"
class="alipay-btn"
target="_blank"
rel="noopener"
>
<i class="fab fa-alipay"></i> 打开支付宝赞助
</a>
<p class="mobile-pay-hint">点击按钮将直接跳转至支付宝转账页面</p>
</div>
</BaseModal>
</main>
</template>
<style scoped>
/* Hero */
.sponsor-hero {
padding: calc(var(--bl-header-height) + 60px) 20px 60px;
text-align: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #fff;
}
.sponsor-hero h1 {
font-size: 48px;
font-weight: 700;
margin: 0 0 24px;
text-shadow: 0 2px 10px rgba(0, 0, 0, 0.15);
}
.total-donations {
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 16px;
}
.counter-label {
font-size: 16px;
opacity: 0.85;
margin-bottom: 8px;
}
.counter-value {
font-size: 56px;
font-weight: 800;
font-family: 'Inter', sans-serif;
letter-spacing: -1px;
}
.hero-subtitle {
font-size: 20px;
opacity: 0.9;
margin: 0;
}
/* Container */
.sponsor-container {
padding: 40px 20px;
}
/* Controls */
.controls-section {
margin-bottom: 32px;
}
.section-title {
font-size: 22px;
font-weight: 700;
margin: 0 0 20px;
}
.controls-header {
display: flex;
gap: 16px;
align-items: center;
margin-bottom: 16px;
flex-wrap: wrap;
}
.search-box {
display: flex;
align-items: center;
gap: 10px;
background: var(--bl-surface-strong);
border: 1px solid rgba(0, 0, 0, 0.08);
border-radius: 12px;
padding: 10px 16px;
flex: 1;
min-width: 200px;
}
.search-box i {
color: var(--bl-text-secondary);
font-size: 14px;
}
.search-box input {
border: none;
outline: none;
background: transparent;
font-size: 15px;
width: 100%;
color: var(--bl-text);
font-family: inherit;
}
.cta-button {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 24px;
border-radius: 12px;
font-size: 15px;
font-weight: 600;
cursor: pointer;
transition: var(--bl-transition);
border: none;
font-family: inherit;
}
.cta-button.outline {
background: transparent;
border: 2px solid var(--bl-accent);
color: var(--bl-accent);
}
.cta-button.outline:hover {
background: var(--bl-accent);
color: #fff;
}
.filter-tags {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.filter-tag {
padding: 6px 16px;
border-radius: 20px;
border: 1.5px solid rgba(0, 0, 0, 0.1);
background: transparent;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: var(--bl-transition);
color: var(--bl-text-secondary);
font-family: inherit;
}
.filter-tag:hover {
border-color: var(--bl-accent);
color: var(--bl-accent);
}
.filter-tag.active {
background: var(--bl-accent);
color: #fff;
border-color: var(--bl-accent);
}
/* Donation Grid */
.donation-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 20px;
}
.donation-card {
background: var(--bl-surface-strong);
border-radius: var(--bl-radius-lg);
padding: 20px;
box-shadow: var(--bl-shadow-soft);
border: 1px solid rgba(0, 0, 0, 0.03);
animation: fadeInUp 0.5s ease both;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(16px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.donation-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 14px;
}
.donor-info {
display: flex;
align-items: center;
gap: 12px;
}
.mini-avatar {
width: 40px;
height: 40px;
border-radius: 10px;
background: #eee;
}
.donor-name {
font-size: 16px;
font-weight: 600;
}
.donation-amount {
font-size: 20px;
font-weight: 700;
color: var(--bl-accent);
font-family: 'Inter', sans-serif;
}
.donation-card-body {
display: flex;
justify-content: space-between;
align-items: center;
padding-top: 12px;
border-top: 1px solid rgba(0, 0, 0, 0.05);
}
.donation-purpose {
font-size: 13px;
color: var(--bl-text-secondary);
font-weight: 500;
}
.donation-date {
font-size: 12px;
color: var(--bl-text-secondary);
display: flex;
align-items: center;
gap: 4px;
}
.donation-date-icon {
font-size: 11px;
}
/* Modal */
.modal-gift-icon {
text-align: center;
margin-bottom: 12px;
}
.modal-gift-icon i {
font-size: 48px;
color: var(--bl-accent);
}
.modal-title {
font-size: 24px;
font-weight: 700;
text-align: center;
margin: 0 0 8px;
}
.modal-subtitle {
font-size: 15px;
color: var(--bl-text-secondary);
text-align: center;
margin: 0;
line-height: 1.5;
}
.desktop-qr-view,
.mobile-btn-view {
text-align: center;
padding: 24px 0 0;
}
.qr-placeholder {
display: inline-block;
padding: 16px;
background: #fff;
border-radius: 16px;
border: 1px solid rgba(0, 0, 0, 0.05);
}
.qr-img {
width: 200px;
height: 200px;
display: block;
}
.desktop-qr-hint {
font-size: 13px;
color: var(--bl-text-secondary);
margin-top: 12px;
}
.alipay-btn {
display: inline-flex;
align-items: center;
gap: 10px;
padding: 14px 32px;
background: #1677ff;
color: #fff;
border-radius: 14px;
text-decoration: none;
font-size: 17px;
font-weight: 700;
transition: var(--bl-transition);
}
.alipay-btn:hover {
background: #0958d9;
transform: translateY(-2px);
}
.mobile-pay-hint {
font-size: 13px;
color: var(--bl-text-secondary);
margin-top: 12px;
}
@media (max-width: 768px) {
.sponsor-hero h1 { font-size: 32px; }
.counter-value { font-size: 40px; }
.donation-grid { grid-template-columns: 1fr; }
.controls-header { flex-direction: column; }
.search-box { width: 100%; }
}
</style>

844
src/pages/StatsPage.vue Normal file
View File

@@ -0,0 +1,844 @@
<script setup>
import { ref, computed, onMounted } from 'vue';
import BaseModal from '../components/base/BaseModal.vue';
import EmptyState from '../components/base/EmptyState.vue';
const allPlayers = ref([]);
const updatedAt = ref('');
const searchQuery = ref('');
const currentPage = ref(1);
const pageSize = 24;
const loading = ref(true);
// Modal
const modalOpen = ref(false);
const selectedPlayer = ref(null);
const detailStats = ref(null);
const detailLoading = ref(false);
// Accordion state: tracks which sections are open
const openSections = ref(new Set());
// Per-section search queries
const sectionSearches = ref({});
onMounted(() => {
fetch('/stats/summary.json')
.then(r => r.json())
.then(data => {
allPlayers.value = data.players;
if (data.updated_at) updatedAt.value = data.updated_at;
loading.value = false;
})
.catch(() => { loading.value = false; });
});
const displayedPlayers = computed(() => {
const q = searchQuery.value.toLowerCase().trim();
if (!q) return allPlayers.value;
return allPlayers.value.filter(p =>
p.name.toLowerCase().includes(q) || p.uuid.toLowerCase().includes(q)
);
});
const visiblePlayers = computed(() => {
return displayedPlayers.value.slice(0, currentPage.value * pageSize);
});
const hasMore = computed(() => {
return visiblePlayers.value.length < displayedPlayers.value.length;
});
function loadMore() {
currentPage.value++;
}
// Reset page on search change
function onSearch(e) {
searchQuery.value = e.target.value;
currentPage.value = 1;
}
// Leaderboards
const leaderboards = computed(() => {
const players = allPlayers.value;
if (!players.length) return [];
function getTop(sortKey, formatFn) {
return [...players]
.sort((a, b) => {
const va = a.stats[sortKey] ?? 0;
const vb = b.stats[sortKey] ?? 0;
return vb - va;
})
.slice(0, 4)
.map(p => ({ ...p, displayValue: formatFn(p) }));
}
return [
{ title: '行走距离', icon: 'fa-walking', color: '#22c55e', top: getTop('walk_raw', p => p.stats.walk_fmt) },
{ title: '放置方块', icon: 'fa-cube', color: '#3b82f6', top: getTop('placed', p => p.stats.placed.toLocaleString()) },
{ title: '挖掘方块', icon: 'fa-hammer', color: '#f59e0b', top: getTop('mined', p => p.stats.mined.toLocaleString()) },
{ title: '死亡次数', icon: 'fa-skull-crossbones', color: '#ef4444', top: getTop('deaths', p => p.stats.deaths.toLocaleString()) },
{ title: '在线时长', icon: 'fa-clock', color: '#8b5cf6', top: getTop('play_time_raw', p => p.stats.play_time_fmt) },
{ title: '击杀数', icon: 'fa-crosshairs', color: '#ec4899', top: getTop('kills', p => p.stats.kills.toLocaleString()) },
];
});
// Modal
function openPlayerModal(player) {
selectedPlayer.value = player;
detailStats.value = null;
detailLoading.value = true;
modalOpen.value = true;
openSections.value = new Set();
sectionSearches.value = {};
fetch(`/stats/${player.uuid}.json`)
.then(r => r.json())
.then(data => {
detailStats.value = data.stats || null;
detailLoading.value = false;
})
.catch(() => { detailLoading.value = false; });
}
function closeModal() {
modalOpen.value = false;
selectedPlayer.value = null;
}
function toggleSection(key) {
const s = new Set(openSections.value);
if (s.has(key)) s.delete(key);
else s.add(key);
openSections.value = s;
}
// Category display
const categoryMap = {
'minecraft:custom': { name: '通用统计', icon: 'fa-chart-bar' },
'minecraft:mined': { name: '挖掘统计', icon: 'fa-hammer' },
'minecraft:used': { name: '使用统计', icon: 'fa-hand-paper' },
'minecraft:crafted': { name: '合成统计', icon: 'fa-tools' },
'minecraft:broken': { name: '破坏统计', icon: 'fa-heart-broken' },
'minecraft:picked_up': { name: '拾取统计', icon: 'fa-box-open' },
'minecraft:dropped': { name: '丢弃统计', icon: 'fa-trash' },
'minecraft:killed': { name: '击杀统计', icon: 'fa-crosshairs' },
'minecraft:killed_by': { name: '死亡统计', icon: 'fa-skull' },
};
function getCategoryInfo(key) {
return categoryMap[key] || { name: formatKey(key), icon: 'fa-folder' };
}
function formatKey(key) {
if (key.includes(':')) key = key.split(':')[1];
return key.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
}
function formatStatValue(catKey, key, value) {
if (catKey === 'minecraft:custom') {
if (key.includes('time') || key.includes('minute')) {
if (key.includes('play_time') || key.includes('time_since')) {
const sec = value / 20;
if (sec > 3600) return (sec / 3600).toFixed(1) + ' h';
if (sec > 60) return (sec / 60).toFixed(1) + ' m';
return sec.toFixed(0) + ' s';
}
}
if (key.includes('cmt') || key.includes('one_cm')) {
const m = value / 100;
if (m > 1000) return (m / 1000).toFixed(2) + ' km';
return m.toFixed(1) + ' m';
}
}
return value.toLocaleString();
}
const sortedCategories = computed(() => {
if (!detailStats.value) return [];
return Object.keys(detailStats.value)
.filter(k => Object.keys(detailStats.value[k]).length > 0)
.sort((a, b) => {
if (a === 'minecraft:custom') return -1;
if (b === 'minecraft:custom') return 1;
return a.localeCompare(b);
});
});
function getSortedItems(catKey) {
const sub = detailStats.value[catKey];
if (!sub) return [];
return Object.entries(sub)
.sort((a, b) => b[1] - a[1])
.map(([k, v], i) => ({
key: k,
label: formatKey(k),
value: formatStatValue(catKey, k, v),
rank: i,
}));
}
function filteredItems(catKey) {
const items = getSortedItems(catKey);
const q = (sectionSearches.value[catKey] || '').toLowerCase().trim();
if (!q) return items;
return items.filter(item => item.label.toLowerCase().includes(q));
}
</script>
<template>
<!-- Hero -->
<section class="page-hero stats-hero">
<div class="hero-overlay"></div>
<div class="hero-content">
<h1 class="hero-title">玩家统计</h1>
<p class="hero-subtitle">探索白鹿原的冒险记录</p>
<p v-if="updatedAt" class="hero-updated">数据更新于 {{ updatedAt }}</p>
</div>
</section>
<main class="stats-container bl-shell">
<div v-if="loading" class="loading-text">正在加载数据...</div>
<template v-else>
<!-- Leaderboards -->
<section class="leaderboards-section">
<h2 class="section-title">🏆 排行榜</h2>
<div class="leaderboards-grid">
<div
v-for="board in leaderboards"
:key="board.title"
class="lb-card"
>
<div class="lb-card-header" :style="{ borderColor: board.color }">
<i class="fas" :class="board.icon" :style="{ color: board.color }"></i>
<span>{{ board.title }}</span>
</div>
<template v-if="board.top.length">
<!-- Top 1 -->
<div class="lb-top-player" @click="openPlayerModal(board.top[0])">
<img
:src="board.top[0].avatar"
:alt="board.top[0].name"
loading="lazy"
@error="($event.target).src = `https://crafatar.com/avatars/${board.top[0].uuid}?size=64&overlay`"
>
<div class="lb-top-name">{{ board.top[0].name }}</div>
<div class="lb-top-data">{{ board.top[0].displayValue }}</div>
</div>
<!-- Runners up -->
<div class="lb-list">
<div
v-for="(p, i) in board.top.slice(1)"
:key="p.uuid"
class="lb-item"
@click="openPlayerModal(p)"
>
<div class="lb-item-main">
<span class="lb-rank">{{ i + 2 }}</span>
<span class="lb-item-name">{{ p.name }}</span>
</div>
<span>{{ p.displayValue }}</span>
</div>
</div>
</template>
<div v-else class="lb-top-player">暂无数据</div>
</div>
</div>
</section>
<!-- Player Grid -->
<section class="players-section">
<h2 class="section-title">👥 全部玩家</h2>
<div class="player-search-box">
<i class="fas fa-search"></i>
<input
type="text"
:value="searchQuery"
placeholder="搜索玩家名或UUID..."
@input="onSearch"
>
</div>
<div v-if="visiblePlayers.length" class="players-grid">
<div
v-for="p in visiblePlayers"
:key="p.uuid"
class="player-card"
@click="openPlayerModal(p)"
>
<img
:src="p.avatar"
:alt="p.name"
loading="lazy"
@error="($event.target).src = `https://crafatar.com/avatars/${p.uuid}?size=64&overlay`"
>
<h3>{{ p.name }}</h3>
</div>
</div>
<EmptyState v-else title="暂无玩家" description="没有找到匹配的玩家。" />
<div v-if="hasMore" class="load-more-wrapper">
<button class="load-more-btn" @click="loadMore">加载更多</button>
</div>
</section>
</template>
<!-- Player Detail Modal -->
<BaseModal :model-value="modalOpen" width="800px" @update:model-value="closeModal">
<template v-if="selectedPlayer" #header>
<div class="modal-player-header">
<img
:src="selectedPlayer.avatar"
class="modal-avatar"
:alt="selectedPlayer.name"
@error="($event.target).src = `https://crafatar.com/avatars/${selectedPlayer.uuid}?size=64&overlay`"
>
<div>
<h3>{{ selectedPlayer.name }}</h3>
<p class="modal-uuid">{{ selectedPlayer.uuid }}</p>
</div>
</div>
</template>
<template v-if="selectedPlayer">
<!-- Summary Stats -->
<div class="summary-stats-grid">
<div class="summary-stat-item">
<span class="summary-stat-label">行走距离</span>
<span class="summary-stat-value">{{ selectedPlayer.stats.walk_fmt }}</span>
</div>
<div class="summary-stat-item">
<span class="summary-stat-label">放置方块</span>
<span class="summary-stat-value">{{ selectedPlayer.stats.placed.toLocaleString() }}</span>
</div>
<div class="summary-stat-item">
<span class="summary-stat-label">挖掘方块</span>
<span class="summary-stat-value">{{ selectedPlayer.stats.mined.toLocaleString() }}</span>
</div>
<div class="summary-stat-item">
<span class="summary-stat-label">死亡</span>
<span class="summary-stat-value">{{ selectedPlayer.stats.deaths }}</span>
</div>
<div class="summary-stat-item">
<span class="summary-stat-label">击杀</span>
<span class="summary-stat-value">{{ selectedPlayer.stats.kills }}</span>
</div>
<div class="summary-stat-item">
<span class="summary-stat-label">在线时长</span>
<span class="summary-stat-value">{{ selectedPlayer.stats.play_time_fmt }}</span>
</div>
</div>
<!-- Detail Accordion -->
<div v-if="detailLoading" class="detail-loading">正在加载详细数据...</div>
<div v-else-if="!detailStats" class="detail-loading">暂无详细统计数据</div>
<div v-else class="stats-accordion">
<div
v-for="catKey in sortedCategories"
:key="catKey"
class="accordion-item"
>
<div
class="accordion-header"
:class="{ active: openSections.has(catKey) }"
@click="toggleSection(catKey)"
>
<span>
<i class="fas" :class="getCategoryInfo(catKey).icon" style="margin-right: 8px;"></i>
{{ getCategoryInfo(catKey).name }}
<span class="item-count">{{ Object.keys(detailStats[catKey]).length }}</span>
</span>
<i class="fas fa-chevron-down arrow"></i>
</div>
<div v-if="openSections.has(catKey)" class="accordion-content show">
<!-- Search for large categories -->
<div v-if="Object.keys(detailStats[catKey]).length >= 20" class="detail-search-wrapper">
<i class="fas fa-search"></i>
<input
type="text"
class="detail-search"
placeholder="搜索条目..."
:value="sectionSearches[catKey] || ''"
@input="sectionSearches[catKey] = ($event.target).value"
>
</div>
<div class="detail-stats-grid">
<div
v-for="item in filteredItems(catKey)"
:key="item.key"
class="detail-stat-item"
:class="{ 'rank-1': item.rank === 0, 'rank-2': item.rank === 1, 'rank-3': item.rank === 2 }"
>
<span class="detail-stat-value">{{ item.value }}</span>
<span class="detail-stat-label" :title="item.label">{{ item.label }}</span>
</div>
</div>
<div v-if="filteredItems(catKey).length === 0" class="detail-no-results">
没有匹配的条目
</div>
</div>
</div>
</div>
</template>
</BaseModal>
</main>
</template>
<style scoped>
.stats-hero {
height: 35vh;
min-height: 300px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
text-align: center;
padding-top: var(--bl-header-height);
background: url('https://img.lunadeer.cn/i/2025/11/26/69267755e14e3.png') center/cover no-repeat;
position: relative;
color: #fff;
}
.hero-overlay {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.4);
z-index: 1;
}
.hero-content {
position: relative;
z-index: 2;
}
.hero-title {
font-size: 56px;
font-weight: 700;
margin: 0 0 10px;
text-shadow: 0 2px 10px rgba(0, 0, 0, 0.3);
}
.hero-subtitle {
font-size: 28px;
font-weight: 400;
color: rgba(255, 255, 255, 0.9);
margin: 0;
text-shadow: 0 2px 5px rgba(0, 0, 0, 0.3);
}
.hero-updated {
font-size: 14px;
opacity: 0.7;
margin-top: 8px;
}
.stats-container {
padding: 40px 20px;
}
.loading-text {
text-align: center;
padding: 60px 0;
font-size: 16px;
color: var(--bl-text-secondary);
}
.section-title {
font-size: 22px;
font-weight: 700;
margin: 0 0 24px;
}
/* Leaderboards */
.leaderboards-section {
margin-bottom: 60px;
}
.leaderboards-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 24px;
}
.lb-card {
background: var(--bl-surface-strong);
border-radius: var(--bl-radius-lg);
overflow: hidden;
box-shadow: var(--bl-shadow-soft);
border: 1px solid rgba(0, 0, 0, 0.03);
}
.lb-card-header {
display: flex;
align-items: center;
gap: 10px;
padding: 16px 20px;
font-size: 16px;
font-weight: 700;
border-bottom: 3px solid;
}
.lb-card-header i {
font-size: 18px;
}
.lb-top-player {
display: flex;
flex-direction: column;
align-items: center;
padding: 24px 20px 16px;
cursor: pointer;
}
.lb-top-player img {
width: 56px;
height: 56px;
border-radius: 50%;
margin-bottom: 10px;
background: #eee;
}
.lb-top-name {
font-size: 16px;
font-weight: 700;
margin-bottom: 4px;
}
.lb-top-data {
font-size: 14px;
color: var(--bl-text-secondary);
font-weight: 600;
}
.lb-list {
padding: 0 20px 16px;
}
.lb-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 0;
border-top: 1px solid rgba(0, 0, 0, 0.05);
font-size: 14px;
cursor: pointer;
}
.lb-item:hover {
color: var(--bl-accent);
}
.lb-item-main {
display: flex;
align-items: center;
gap: 10px;
}
.lb-rank {
width: 22px;
height: 22px;
border-radius: 50%;
background: #f0f0f2;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 700;
color: var(--bl-text-secondary);
}
.lb-item-name {
font-weight: 500;
}
/* Player Grid */
.players-section {
margin-bottom: 40px;
}
.player-search-box {
display: flex;
align-items: center;
gap: 10px;
background: var(--bl-surface-strong);
border: 1px solid rgba(0, 0, 0, 0.08);
border-radius: 12px;
padding: 10px 16px;
width: 100%;
max-width: 400px;
margin-bottom: 24px;
}
.player-search-box i {
color: var(--bl-text-secondary);
font-size: 14px;
}
.player-search-box input {
border: none;
outline: none;
background: transparent;
font-size: 15px;
width: 100%;
color: var(--bl-text);
font-family: inherit;
}
.players-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
gap: 16px;
}
.player-card {
display: flex;
flex-direction: column;
align-items: center;
padding: 24px 12px;
background: var(--bl-surface-strong);
border-radius: var(--bl-radius-lg);
cursor: pointer;
transition: var(--bl-transition);
box-shadow: var(--bl-shadow-soft);
border: 1px solid rgba(0, 0, 0, 0.03);
}
.player-card:hover {
transform: translateY(-4px);
box-shadow: var(--bl-shadow-card);
}
.player-card img {
width: 56px;
height: 56px;
border-radius: 50%;
margin-bottom: 10px;
background: #eee;
}
.player-card h3 {
font-size: 14px;
font-weight: 600;
margin: 0;
text-align: center;
word-break: break-all;
}
.load-more-wrapper {
text-align: center;
margin-top: 32px;
}
.load-more-btn {
padding: 10px 32px;
border-radius: 12px;
border: 1.5px solid rgba(0, 0, 0, 0.12);
background: transparent;
color: var(--bl-text);
font-size: 15px;
font-weight: 600;
cursor: pointer;
transition: var(--bl-transition);
font-family: inherit;
}
.load-more-btn:hover {
background: var(--bl-accent);
color: #fff;
border-color: var(--bl-accent);
}
/* Modal */
.modal-player-header {
display: flex;
align-items: center;
gap: 16px;
}
.modal-avatar {
width: 56px;
height: 56px;
border-radius: 50%;
background: #eee;
}
.modal-player-header h3 {
font-size: 24px;
font-weight: 700;
margin: 0 0 4px;
}
.modal-uuid {
font-size: 12px;
font-family: monospace;
color: var(--bl-text-secondary);
margin: 0;
}
.summary-stats-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
margin-bottom: 32px;
}
.summary-stat-item {
background: #f9f9fa;
border-radius: 12px;
padding: 16px;
text-align: center;
}
.summary-stat-label {
display: block;
font-size: 12px;
color: var(--bl-text-secondary);
margin-bottom: 6px;
font-weight: 500;
}
.summary-stat-value {
display: block;
font-size: 18px;
font-weight: 700;
font-family: 'Inter', sans-serif;
}
.detail-loading {
text-align: center;
padding: 24px;
color: var(--bl-text-secondary);
}
/* Accordion */
.stats-accordion {
display: flex;
flex-direction: column;
gap: 8px;
}
.accordion-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 14px 18px;
background: #f5f5f7;
border-radius: 12px;
cursor: pointer;
font-size: 15px;
font-weight: 600;
transition: var(--bl-transition);
user-select: none;
}
.accordion-header:hover {
background: #ebebed;
}
.accordion-header .arrow {
font-size: 12px;
transition: transform 0.3s;
color: var(--bl-text-secondary);
}
.accordion-header.active .arrow {
transform: rotate(180deg);
}
.item-count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 22px;
height: 20px;
background: rgba(0, 0, 0, 0.08);
border-radius: 10px;
font-size: 11px;
font-weight: 600;
margin-left: 8px;
padding: 0 6px;
}
.accordion-content {
padding: 16px 18px;
}
.detail-search-wrapper {
display: flex;
align-items: center;
gap: 8px;
background: #fff;
border: 1px solid rgba(0, 0, 0, 0.08);
border-radius: 10px;
padding: 8px 14px;
margin-bottom: 16px;
}
.detail-search-wrapper i {
color: var(--bl-text-secondary);
font-size: 12px;
}
.detail-search {
border: none;
outline: none;
background: transparent;
font-size: 14px;
width: 100%;
color: var(--bl-text);
font-family: inherit;
}
.detail-stats-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 10px;
}
.detail-stat-item {
background: #fff;
border: 1px solid rgba(0, 0, 0, 0.05);
border-radius: 10px;
padding: 12px;
text-align: center;
}
.detail-stat-item.rank-1 { border-color: #ffd700; background: #fffef5; }
.detail-stat-item.rank-2 { border-color: #c0c0c0; background: #fafafa; }
.detail-stat-item.rank-3 { border-color: #cd7f32; background: #fefaf5; }
.detail-stat-value {
display: block;
font-size: 16px;
font-weight: 700;
font-family: 'Inter', sans-serif;
margin-bottom: 4px;
}
.detail-stat-label {
display: block;
font-size: 11px;
color: var(--bl-text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.detail-no-results {
text-align: center;
padding: 16px;
color: var(--bl-text-secondary);
font-size: 14px;
}
@media (max-width: 768px) {
.hero-title { font-size: 36px; }
.hero-subtitle { font-size: 20px; }
.leaderboards-grid { grid-template-columns: 1fr; }
.summary-stats-grid { grid-template-columns: repeat(2, 1fr); }
.players-grid { grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); }
}
</style>

674
src/pages/TownsPage.vue Normal file
View File

@@ -0,0 +1,674 @@
<script setup>
import { ref, computed, onMounted, nextTick } from 'vue';
import { useRoute } from 'vue-router';
import FilterPanel from '../components/shared/FilterPanel.vue';
import BaseBadge from '../components/base/BaseBadge.vue';
import BaseModal from '../components/base/BaseModal.vue';
import ModalSection from '../components/detail/ModalSection.vue';
import EmptyState from '../components/base/EmptyState.vue';
const route = useRoute();
const DEFAULT_GRADIENT = { from: '#667eea', to: '#764ba2' };
const towns = ref([]);
const searchQuery = ref('');
const scaleFilter = ref('all');
const typeFilter = ref('all');
const recruitFilter = ref('all');
const modalOpen = ref(false);
const selectedTown = ref(null);
const sharedId = ref(null);
const editMode = ref(false);
// Secret edit shortcut
let secretBuffer = '';
function onSecretKey(e) {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
secretBuffer += e.key.toLowerCase();
if (secretBuffer.length > 4) secretBuffer = secretBuffer.slice(-4);
if (secretBuffer === 'edit') { editMode.value = !editMode.value; secretBuffer = ''; }
}
onMounted(() => {
document.addEventListener('keydown', onSecretKey);
fetch('/data/towns.json')
.then(r => r.json())
.then(data => {
towns.value = data;
nextTick(() => handleHash());
});
});
function handleHash() {
const hash = route.hash.replace('#', '');
if (!hash) return;
const match = towns.value.find(item => generateId(item) === hash);
if (match) openModal(match);
}
function generateId(item) {
const raw = item.title || '';
let h = 0;
for (let i = 0; i < raw.length; i++) {
h = ((h << 5) - h) + raw.charCodeAt(i);
h |= 0;
}
return 't' + Math.abs(h).toString(36);
}
// Filter options
const scaleOptions = [
{ value: 'all', label: '全部' },
{ value: 'small', label: '小型' },
{ value: 'medium', label: '中型' },
{ value: 'large', label: '大型' },
];
const typeOptions = [
{ value: 'all', label: '全部' },
{ value: 'building', label: '建筑' },
{ value: 'adventure', label: '冒险' },
{ value: 'industry', label: '工业' },
];
const recruitOptions = [
{ value: 'all', label: '全部' },
{ value: 'welcome', label: '欢迎加入' },
{ value: 'maybe', label: '可以考虑' },
{ value: 'closed', label: '暂不招人' },
];
// Maps
const scaleTextMap = { small: '小型5人以下', medium: '中型2-10人', large: '大型10人以上' };
const scaleIconMap = { small: 'fa-user', medium: 'fa-users', large: 'fa-city' };
const typeTextMap = { building: '建筑', adventure: '冒险', industry: '工业' };
const typeIconMap = { building: 'fa-building', adventure: 'fa-dragon', industry: 'fa-industry' };
const recruitTextMap = { welcome: '欢迎加入', closed: '暂不招人', maybe: '可以考虑' };
const recruitIconMap = { welcome: 'fa-door-open', closed: 'fa-door-closed', maybe: 'fa-question-circle' };
const dimensionTextMap = { overworld: '主世界', nether: '下界', the_end: '末地' };
function getGradient(item) {
const g = item?.gradient || {};
const from = /^#[0-9a-fA-F]{6}$/.test((g.from || '').trim()) ? g.from.trim() : DEFAULT_GRADIENT.from;
const to = /^#[0-9a-fA-F]{6}$/.test((g.to || '').trim()) ? g.to.trim() : DEFAULT_GRADIENT.to;
return { from, to };
}
function gradientStyle(item) {
const g = getGradient(item);
return `linear-gradient(135deg, ${g.from} 0%, ${g.to} 100%)`;
}
const filtered = computed(() => {
return towns.value.filter(item => {
const matchScale = scaleFilter.value === 'all' || item.scale === scaleFilter.value;
const matchType = typeFilter.value === 'all' || item.townType === typeFilter.value;
const matchRecruit = recruitFilter.value === 'all' || item.recruitment === recruitFilter.value;
const q = searchQuery.value.toLowerCase().trim();
const matchSearch = !q || item.title.toLowerCase().includes(q);
return matchScale && matchType && matchRecruit && matchSearch;
});
});
function openModal(item) {
selectedTown.value = item;
modalOpen.value = true;
history.replaceState(null, '', location.pathname + '#' + generateId(item));
}
function closeModal() {
modalOpen.value = false;
selectedTown.value = null;
history.replaceState(null, '', location.pathname + location.search);
}
function shareItem(item) {
const id = generateId(item);
const url = location.origin + location.pathname + '#' + id;
navigator.clipboard.writeText(url).then(() => {
sharedId.value = id;
setTimeout(() => { sharedId.value = null; }, 2000);
});
}
function getMapUrl(item) {
if (!item.coordinates) return '#';
const c = item.coordinates;
const d = item.dimension || 'overworld';
const world = d === 'nether' ? 'world_nether' : d === 'the_end' ? 'world_the_end' : 'world';
return `https://mcmap.lunadeer.cn/#${world}:${c.x}:${c.y}:${c.z}:500:0:0:0:1:flat`;
}
function parseBV(input) {
if (!input) return null;
const m = input.trim().match(/(BV[A-Za-z0-9]{10,})/);
return m ? m[1] : null;
}
function onFilterChange({ key, value }) {
if (key === 'scale') scaleFilter.value = value;
if (key === 'type') typeFilter.value = value;
if (key === 'recruit') recruitFilter.value = value;
}
function hasLogo(item) {
return item.logo && item.logo.trim() !== '';
}
</script>
<template>
<!-- Hero -->
<section class="page-hero towns-hero">
<div class="hero-overlay"></div>
<div class="hero-content">
<h1 class="hero-title">聚落与城镇</h1>
<p class="hero-subtitle">探索服务器中的社区据点</p>
</div>
</section>
<main class="towns-container bl-shell">
<!-- Controls -->
<FilterPanel
title="城镇列表"
:search-value="searchQuery"
search-placeholder="搜索城镇名称..."
:filters="[
{ key: 'scale', label: '规模', options: scaleOptions, modelValue: scaleFilter },
{ key: 'type', label: '类型', options: typeOptions, modelValue: typeFilter },
{ key: 'recruit', label: '招募', options: recruitOptions, modelValue: recruitFilter },
]"
@update:search-value="searchQuery = $event"
@change-filter="onFilterChange"
/>
<!-- Grid -->
<div v-if="filtered.length" class="towns-grid">
<article
v-for="item in filtered"
:key="generateId(item)"
class="town-card"
@click="openModal(item)"
>
<div
class="town-card-bg"
:class="{ 'no-logo': !hasLogo(item) }"
:style="hasLogo(item)
? { backgroundImage: `url('${item.logo}')` }
: { background: gradientStyle(item) }"
>
<i v-if="!hasLogo(item)" class="fas fa-city town-logo-placeholder"></i>
<div class="town-card-icons">
<span class="town-icon-badge" :class="'icon-scale-' + item.scale" :title="scaleTextMap[item.scale]">
<i class="fas" :class="scaleIconMap[item.scale]"></i>
</span>
<span class="town-icon-badge" :class="'icon-type-' + item.townType" :title="typeTextMap[item.townType]">
<i class="fas" :class="typeIconMap[item.townType]"></i>
</span>
<span class="town-icon-badge" :class="'icon-recruit-' + item.recruitment" :title="recruitTextMap[item.recruitment]">
<i class="fas" :class="recruitIconMap[item.recruitment]"></i>
</span>
</div>
</div>
<div class="town-card-body">
<h3 class="town-card-title">{{ item.title }}</h3>
<div class="town-card-meta">
<span class="town-meta-tag"><i class="fas" :class="scaleIconMap[item.scale]"></i> {{ scaleTextMap[item.scale] }}</span>
<span class="town-meta-tag"><i class="fas" :class="typeIconMap[item.townType]"></i> {{ typeTextMap[item.townType] }}</span>
<span class="town-meta-tag"><i class="fas" :class="recruitIconMap[item.recruitment]"></i> {{ recruitTextMap[item.recruitment] }}</span>
</div>
</div>
</article>
</div>
<EmptyState v-else title="暂无城镇" description="当前没有匹配的城镇信息。" />
<!-- Detail Modal -->
<BaseModal :model-value="modalOpen" width="720px" @update:model-value="closeModal">
<template v-if="selectedTown" #header>
<!-- Banner -->
<div
class="town-modal-banner"
:class="{ 'no-logo': !hasLogo(selectedTown) }"
:style="hasLogo(selectedTown)
? { backgroundImage: `url('${selectedTown.logo}')` }
: { background: gradientStyle(selectedTown) }"
>
<i v-if="!hasLogo(selectedTown)" class="fas fa-city town-banner-placeholder"></i>
</div>
<div class="modal-header-inner">
<h3>{{ selectedTown.title }}</h3>
<div class="modal-badges-row">
<div class="modal-badges">
<span class="town-badge" :class="'badge-scale-' + selectedTown.scale">
<i class="fas" :class="scaleIconMap[selectedTown.scale]"></i>
{{ scaleTextMap[selectedTown.scale] }}
</span>
<span class="town-badge" :class="'badge-type-' + selectedTown.townType">
<i class="fas" :class="typeIconMap[selectedTown.townType]"></i>
{{ typeTextMap[selectedTown.townType] }}
</span>
<span class="town-badge" :class="'badge-recruit-' + selectedTown.recruitment">
<i class="fas" :class="recruitIconMap[selectedTown.recruitment]"></i>
{{ recruitTextMap[selectedTown.recruitment] }}
</span>
</div>
<div class="modal-actions">
<button
type="button"
:class="['btn-share', { shared: sharedId === generateId(selectedTown) }]"
@click="shareItem(selectedTown)"
>
{{ sharedId === generateId(selectedTown) ? '✓ 已复制' : '🔗 分享' }}
</button>
</div>
</div>
</div>
</template>
<template v-if="selectedTown">
<!-- Location -->
<ModalSection title="位置信息">
<p v-if="selectedTown.coordinatesSecret">保密</p>
<p v-else>
{{ dimensionTextMap[selectedTown.dimension] || '主世界' }}
<template v-if="selectedTown.coordinates">
· X: {{ selectedTown.coordinates.x }}, Y: {{ selectedTown.coordinates.y }}, Z: {{ selectedTown.coordinates.z }}
</template>
<a
v-if="selectedTown.coordinates"
:href="getMapUrl(selectedTown)"
target="_blank"
rel="noopener"
class="map-link"
>
🗺 在地图中查看
</a>
</p>
</ModalSection>
<!-- Founders -->
<ModalSection title="创始人">
<div v-if="selectedTown.founders?.length" class="contributors-list">
<span v-for="name in selectedTown.founders" :key="name" class="contributor-tag">
<img :src="`https://minotar.net/avatar/${encodeURIComponent(name)}/20`" :alt="name" loading="lazy">
{{ name }}
</span>
</div>
<span v-else class="text-secondary">暂无记录</span>
</ModalSection>
<!-- Members -->
<ModalSection title="成员">
<div v-if="selectedTown.members?.length" class="contributors-list">
<span v-for="name in selectedTown.members" :key="name" class="contributor-tag">
<img :src="`https://minotar.net/avatar/${encodeURIComponent(name)}/20`" :alt="name" loading="lazy">
{{ name }}
</span>
</div>
<span v-else class="text-secondary">暂无记录</span>
</ModalSection>
<!-- Introduction -->
<ModalSection v-if="selectedTown.introduction?.length" title="城镇介绍">
<div class="content-blocks">
<template v-for="(block, bi) in selectedTown.introduction" :key="bi">
<p v-if="block.type === 'text'">{{ block.content }}</p>
<img v-else-if="block.type === 'image'" :src="block.content" loading="lazy" alt="">
<div v-else-if="block.type === 'video' && parseBV(block.content)" class="video-embed-wrapper">
<iframe
:src="`https://player.bilibili.com/player.html?bvid=${parseBV(block.content)}&autoplay=0&high_quality=1`"
allowfullscreen
sandbox="allow-scripts allow-same-origin allow-popups"
loading="lazy"
></iframe>
</div>
</template>
</div>
</ModalSection>
</template>
</BaseModal>
</main>
</template>
<style scoped>
.towns-hero {
height: 35vh;
min-height: 300px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
text-align: center;
padding-top: var(--bl-header-height);
background: url('https://img.lunadeer.cn/i/2025/11/26/69267755e14e3.png') center/cover no-repeat;
position: relative;
color: #fff;
}
.hero-overlay {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.4);
z-index: 1;
}
.hero-content {
position: relative;
z-index: 2;
}
.hero-title {
font-size: 56px;
font-weight: 700;
margin: 0 0 10px;
text-shadow: 0 2px 10px rgba(0, 0, 0, 0.3);
}
.hero-subtitle {
font-size: 28px;
font-weight: 400;
color: rgba(255, 255, 255, 0.9);
margin: 0;
text-shadow: 0 2px 5px rgba(0, 0, 0, 0.3);
}
.towns-container {
padding: 40px 20px;
}
/* Grid */
.towns-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 24px;
margin-top: 40px;
}
/* Card */
.town-card {
border-radius: var(--bl-radius-lg);
overflow: hidden;
background: var(--bl-surface-strong);
box-shadow: var(--bl-shadow-soft);
cursor: pointer;
transition: var(--bl-transition);
border: 1px solid rgba(0, 0, 0, 0.03);
}
.town-card:hover {
transform: translateY(-4px);
box-shadow: var(--bl-shadow-card);
}
.town-card-bg {
height: 180px;
background-size: cover;
background-position: center;
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
.town-card-bg.no-logo {
background-size: unset;
}
.town-logo-placeholder {
font-size: 48px;
color: rgba(255, 255, 255, 0.4);
}
.town-card-icons {
position: absolute;
bottom: 10px;
right: 10px;
display: flex;
gap: 6px;
}
.town-icon-badge {
display: flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.45);
color: #fff;
font-size: 13px;
backdrop-filter: blur(4px);
}
.town-card-body {
padding: 18px 20px;
}
.town-card-title {
font-size: 18px;
font-weight: 700;
margin: 0 0 12px;
}
.town-card-meta {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.town-meta-tag {
font-size: 11px;
background: #f5f5f7;
padding: 4px 10px;
border-radius: 6px;
color: var(--bl-text-secondary);
font-weight: 500;
}
.town-meta-tag i {
margin-right: 4px;
}
/* Modal banner */
.town-modal-banner {
width: 100%;
height: 200px;
background-size: cover;
background-position: center;
border-radius: var(--bl-radius-lg) var(--bl-radius-lg) 0 0;
display: flex;
align-items: center;
justify-content: center;
margin: -32px -32px 20px;
width: calc(100% + 64px);
}
.town-modal-banner.no-logo {
background-size: unset;
}
.town-banner-placeholder {
font-size: 64px;
color: rgba(255, 255, 255, 0.35);
}
.modal-header-inner h3 {
font-size: 32px;
font-weight: 700;
margin: 0 0 16px;
line-height: 1.2;
}
.modal-badges-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
flex-wrap: wrap;
}
.modal-badges {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.modal-actions {
display: flex;
gap: 8px;
flex-shrink: 0;
}
/* Town badges */
.town-badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 14px;
border-radius: 20px;
font-size: 13px;
font-weight: 600;
}
.town-badge i {
font-size: 12px;
}
.badge-scale-small { background: #e8f5e9; color: #2e7d32; }
.badge-scale-medium { background: #e3f2fd; color: #1565c0; }
.badge-scale-large { background: #fce4ec; color: #c62828; }
.badge-type-building { background: #fff3e0; color: #e65100; }
.badge-type-adventure { background: #f3e5f5; color: #6a1b9a; }
.badge-type-industry { background: #e0f2f1; color: #00695c; }
.badge-recruit-welcome { background: #e8f5e9; color: #2e7d32; }
.badge-recruit-closed { background: #ffebee; color: #c62828; }
.badge-recruit-maybe { background: #fff8e1; color: #f57f17; }
.btn-share {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 16px;
background: transparent;
color: var(--bl-text-secondary);
border: 1.5px solid rgba(0, 0, 0, 0.12);
border-radius: 18px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: var(--bl-transition);
}
.btn-share:hover {
color: var(--bl-accent);
border-color: var(--bl-accent);
}
.btn-share.shared {
color: #15803d;
border-color: var(--bl-green);
background: #e8fceb;
}
.map-link {
display: inline-flex;
align-items: center;
gap: 6px;
color: #fff;
background: var(--bl-accent);
padding: 6px 16px;
border-radius: 20px;
text-decoration: none;
font-weight: 500;
font-size: 13px;
margin-left: 12px;
transition: 0.2s;
}
.map-link:hover {
background: var(--bl-accent-strong);
transform: translateY(-1px);
}
.contributors-list {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.contributor-tag {
display: flex;
align-items: center;
background: #fff;
border: 1px solid #eee;
padding: 6px 14px;
border-radius: 30px;
font-size: 14px;
color: var(--bl-text);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.02);
}
.contributor-tag img {
width: 24px;
height: 24px;
border-radius: 50%;
margin-right: 10px;
background: #eee;
}
.text-secondary {
color: var(--bl-text-secondary);
font-size: 14px;
}
.content-blocks {
background: #f9f9fa;
padding: 24px;
border-radius: 16px;
border: 1px solid rgba(0, 0, 0, 0.03);
}
.content-blocks p {
font-size: 15px;
margin: 0 0 12px;
line-height: 1.7;
}
.content-blocks p:last-child {
margin-bottom: 0;
}
.content-blocks img {
max-width: 100%;
border-radius: 12px;
margin: 12px 0 20px;
border: 1px solid rgba(0, 0, 0, 0.05);
}
.video-embed-wrapper {
position: relative;
width: 100%;
padding-bottom: 56.25%;
margin: 12px 0 20px;
border-radius: 12px;
overflow: hidden;
background: #000;
}
.video-embed-wrapper iframe {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
border: none;
}
@media (max-width: 768px) {
.hero-title { font-size: 36px; }
.hero-subtitle { font-size: 20px; }
.towns-grid { grid-template-columns: 1fr; }
.modal-header-inner h3 { font-size: 24px; }
.town-modal-banner { height: 140px; }
}
</style>

70
src/router.js Normal file
View File

@@ -0,0 +1,70 @@
import { createRouter, createWebHistory } from 'vue-router';
const routes = [
{
path: '/',
name: 'home',
component: () => import('./pages/HomePage.vue'),
},
{
path: '/announcements',
name: 'announcements',
component: () => import('./pages/AnnouncementsPage.vue'),
},
{
path: '/facilities',
name: 'facilities',
component: () => import('./pages/FacilitiesPage.vue'),
},
{
path: '/towns',
name: 'towns',
component: () => import('./pages/TownsPage.vue'),
},
{
path: '/stats',
name: 'stats',
component: () => import('./pages/StatsPage.vue'),
},
{
path: '/sponsor',
name: 'sponsor',
component: () => import('./pages/SponsorPage.vue'),
},
{
path: '/join',
name: 'join',
component: () => import('./pages/JoinPage.vue'),
},
{
path: '/doc',
name: 'doc',
component: () => import('./pages/DocPage.vue'),
},
{
path: '/map',
name: 'map',
component: () => import('./pages/MapPage.vue'),
},
{
path: '/photo',
name: 'photo',
component: () => import('./pages/PhotoPage.vue'),
},
];
const router = createRouter({
history: createWebHistory(),
routes,
scrollBehavior(to, from, savedPosition) {
if (to.hash) {
return { el: to.hash, behavior: 'smooth' };
}
if (savedPosition) {
return savedPosition;
}
return { top: 0 };
},
});
export default router;