feat: add shared components for donation, facility, feature bento, filter panel, join wizard, leaderboard, player, playstyle, and town cards

- Implemented DonationCard.vue for displaying donation details.
- Created FacilityCard.vue to showcase facility information with status badges.
- Developed FeatureBentoCard.vue and FeatureBentoGrid.vue for feature display in a grid layout.
- Added FilterPanel.vue for filtering content with search and tag options.
- Introduced JoinWizard.vue for a step-by-step joining process with device and playstyle selection.
- Created LeaderboardCard.vue to display leaderboard information.
- Implemented PlayerCard.vue for showcasing player profiles and stats.
- Developed PlaystyleCard.vue for selecting playstyle options.
- Added TownCard.vue to present town details with badges and images.
- Included demo data in demoData.js for testing and development purposes.
This commit is contained in:
zhangyuheng
2026-03-18 10:50:23 +08:00
parent 9db782ae4b
commit d254ec86df
35 changed files with 3782 additions and 79 deletions

View File

@@ -0,0 +1,127 @@
---
description: "Use when rebuilding old-html-ver pages into Vue UI components, page layouts, composables, or styles for the bailuyuan website. Covers component extraction, custom UI design, and canonical legacy pattern selection."
name: "Vue UI Migration"
applyTo: "src/**/*.vue, src/**/*.js, src/**/*.css"
---
# Bailuyuan Vue UI Migration Guidelines
## Goal
- Rebuild the legacy pages in `old-html-ver/` with Vue 3 components in `src/`.
- Do not use external UI component libraries.
- Recreate the site's visual language from the legacy HTML/CSS, but normalize duplicated styles into a smaller, cleaner component system.
- When similar legacy UIs use different implementations, keep the strongest pattern and discard weaker duplicates.
## Source Priority
- Treat `old-html-ver/js/components.js` as the source of truth for shared layout primitives: navbar, mobile menu, page hero, footer.
- Treat `old-html-ver/css/style.css` as the source of truth for global tokens and shared layout behavior.
- Treat `old-html-ver/announcements.html`, `old-html-ver/facilities.html`, and `old-html-ver/towns.html` plus their page CSS as the canonical source for search, filter, card, badge, and detail interaction patterns.
- Treat `old-html-ver/index.html` as the source for the home-page-only bento grid and hero interaction patterns.
- Treat `old-html-ver/stats.html`, `old-html-ver/sponsor.html`, and `old-html-ver/join.html` as sources for page-specific specialized components, not as the default style baseline for shared controls.
## Canonical Pattern Choices
- Use the announcements/facilities/towns control bar as the standard filter UI:
- card-like `controls-section`
- `search-box` with leading icon
- labeled `filter-group`
- pill `filter-tag` buttons with a strong active state
- Prefer the facilities/towns card and modal structure as the base pattern for data-detail pages.
- Keep the announcement timeline as a dedicated page pattern instead of forcing it into the facilities/towns card layout.
- Keep leaderboard cards, donation cards, and join wizard cards as specialized components that inherit shared spacing, radius, shadow, and button rules from base UI primitives.
- If a sponsor or stats page control differs from the announcements/facilities/towns version, default to the latter unless the page has a real functional need for a custom variant.
## Components To Extract First
- Layout primitives:
- `SiteNavbar`
- `MobileNavDrawer`
- `PageHero`
- `SiteFooter`
- Base UI primitives:
- `BaseButton`
- `BaseCard`
- `BaseModal`
- `BaseBadge`
- `SearchBox`
- `FilterTagGroup`
- `EmptyState`
- `LoadMoreButton`
- Shared content components:
- `FilterPanel`
- `FacilityCard`
- `TownCard`
- `AnnouncementTimeline`
- `AnnouncementCard`
- `LeaderboardCard`
- `PlayerCard`
- `DonationCard`
- `FeatureBentoGrid`
- `FeatureBentoCard`
- `JoinWizard`
- `DeviceCard`
- `PlaystyleCard`
- Detail components:
- `FacilityDetailModal`
- `TownDetailModal`
- `PlayerDetailModal`
- `SponsorModal`
- `ModalSection`
## Expected Component Responsibilities
- Shared components must be prop-driven and reusable across multiple pages.
- Page components should compose shared components instead of duplicating old HTML blocks.
- Data-driven sections should accept structured props for badges, coordinates, contributor lists, rich text blocks, media blocks, and status labels.
- Repeated filter and search logic should move into Vue composables instead of being reimplemented in each page.
## Styling Rules
- Keep the site's existing visual direction: soft cards, rounded corners, translucent navigation, strong hero imagery, restrained shadows, and Chinese content-first spacing.
- Reuse the legacy CSS variable vocabulary where it still makes sense, but consolidate it in the Vue codebase instead of copying page CSS wholesale.
- Do not copy-paste duplicated legacy class trees unless they are the chosen canonical pattern.
- Normalize spacing, radii, shadows, and interactive states across components.
- Preserve responsive behavior from the legacy site, especially navbar/mobile menu, hero scaling, filter wrapping, grid collapse, and modal usability.
- When creating styles, prefer local component styles or clearly organized shared styles over page-specific one-off overrides.
## Architecture Rules For Vue Work
- Build shared primitives before page-specific wrappers.
- Use slots only when content structure truly varies; otherwise use typed props with clear names.
- Keep interaction state inside the component or a focused composable such as search, filtering, modal visibility, and pagination.
- Avoid direct DOM manipulation when Vue state and template bindings can express the behavior.
- Preserve current data contracts from `public/data/` and `public/stats/` unless the task explicitly includes changing them.
## Legacy To Vue Mapping
- `components.js` navbar/footer/hero -> shared layout components.
- Announcements page -> `FilterPanel` + `AnnouncementTimeline` + expandable `AnnouncementCard`.
- Facilities page -> `FilterPanel` + `FacilityCard` grid + `FacilityDetailModal`.
- Towns page -> `FilterPanel` + `TownCard` grid + `TownDetailModal`.
- Stats page -> `LeaderboardCard` grid + `PlayerCard` grid + `PlayerDetailModal` + pagination controls.
- Sponsor page -> `DonationCard` grid + `SponsorModal`, while still reusing shared search and filter primitives.
- Join page -> `JoinWizard`, `ProgressStep`, `DeviceCard`, `EditionToggle`, `PlaystyleCard`.
- Home page -> `PageHero` specialization + `FeatureBentoGrid` + sponsor highlight section.
## What To Avoid
- Do not introduce Element Plus, Vuetify, Naive UI, Ant Design Vue, or similar UI kits.
- Do not keep one Vue component per old HTML page section if the section is really a shared pattern.
- Do not preserve inconsistent legacy styling just because it already exists.
- Do not port legacy imperative JavaScript event wiring directly into Vue components.
- Do not silently invent a new visual language that ignores the old site structure and tone.
## Default Implementation Bias
- If multiple legacy versions exist, choose the version that is clearer, more reusable, and visually more stable.
- For search and filtering, bias toward the announcements/facilities/towns implementation.
- For detail dialogs, bias toward the facilities/towns modal structure.
- For cards on data listing pages, bias toward the facilities/towns content density instead of the lighter sponsor/stats cards unless the page truly needs the lighter form.
## When Generating New Vue UI
- State which legacy page and which legacy pattern you are mapping from.
- List which shared component should be reused before creating a new one.
- If creating a new component, explain why an existing shared primitive is insufficient.
- Keep markup semantic and accessible: buttons for actions, labels for fields, dialog semantics for modals, keyboard-friendly navigation states.

View File

@@ -1,26 +1,322 @@
<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';
const searchValue = ref('');
const selectedFacilityType = ref('all');
const selectedFacilityDimension = ref('all');
const facilityModalOpen = ref(false);
const townModalOpen = ref(false);
const playerModalOpen = ref(false);
const sponsorModalOpen = ref(false);
const activeFacility = ref(facilityItems[0]);
const activeTown = ref(townItems[0]);
const activePlayer = ref(playerItems[0]);
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: '下界' },
],
},
]);
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> <template>
<main class="app-shell"> <div class="showcase-page">
<section class="hero-card"> <SiteNavbar :items="navItems" active-path="/facilities.html" />
<p class="eyebrow">Vue Migration Workspace</p>
<h1>白鹿原官网 Vue 重构基座已就绪</h1> <PageHero
<p class="intro"> eyebrow="Vue UI Migration"
这里是新的 Vue 入口旧版静态站仍保留在 old-html-ver 目录中已迁入的新工作流会从 public 目录提供数据与静态资源 title="白鹿原基础 UI 组件审查页"
</p> 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>
<section class="status-grid"> <section class="showcase-section">
<article> <div class="bl-section-heading">
<h2>public 资源</h2> <div>
<p>旧站数据文件统计 JSON SEO 静态文件会从这里进入 Vite 构建产物</p> <p class="showcase-kicker">Controls</p>
</article> <h2 class="bl-section-title">Search / Filter 标准模式</h2>
<article> </div>
<h2>scripts 任务</h2> <p class="bl-section-copy"> announcements / facilities / towns controls-section canonical pattern</p>
<p>玩家统计脚本已迁到根目录工作流可在构建前更新 public/stats</p> </div>
</article>
<article> <FilterPanel
<h2>下一步</h2> title="设施列表"
<p>后续可以在 src 下逐页重建组件路由和布局而不需要再改项目基础设施</p> :search-value="searchValue"
</article> 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> </section>
</main> </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> </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>

View File

@@ -0,0 +1,71 @@
<script setup>
defineProps({
tone: {
type: String,
default: 'neutral',
},
size: {
type: String,
default: 'md',
},
});
</script>
<template>
<span :class="['base-badge', `base-badge--${tone}`, `base-badge--${size}`]">
<slot />
</span>
</template>
<style scoped>
.base-badge {
display: inline-flex;
align-items: center;
gap: 6px;
border-radius: 999px;
font-weight: 700;
white-space: nowrap;
}
.base-badge--sm {
min-height: 22px;
padding: 0 9px;
font-size: 0.7rem;
}
.base-badge--md {
min-height: 26px;
padding: 0 11px;
font-size: 0.76rem;
}
.base-badge--neutral {
background: #f1f2f4;
color: var(--bl-text-secondary);
}
.base-badge--accent {
background: rgba(0, 113, 227, 0.12);
color: var(--bl-accent-strong);
}
.base-badge--success {
background: #e8fceb;
color: #15803d;
}
.base-badge--warning {
background: #fff7db;
color: #b45309;
}
.base-badge--danger {
background: #feebeb;
color: #b91c1c;
}
.base-badge--purple {
background: #f3f0ff;
color: #6d28d9;
}
</style>

View File

@@ -0,0 +1,119 @@
<script setup>
defineProps({
variant: {
type: String,
default: 'primary',
},
size: {
type: String,
default: 'md',
},
block: {
type: Boolean,
default: false,
},
disabled: {
type: Boolean,
default: false,
},
type: {
type: String,
default: 'button',
},
});
</script>
<template>
<button
:type="type"
:disabled="disabled"
:class="['base-button', `base-button--${variant}`, `base-button--${size}`, { 'base-button--block': block }]"
>
<slot />
</button>
</template>
<style scoped>
.base-button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
border-radius: 999px;
font-weight: 600;
cursor: pointer;
transition: var(--bl-transition);
white-space: nowrap;
}
.base-button--sm {
min-height: 34px;
padding: 0 14px;
font-size: 0.84rem;
}
.base-button--md {
min-height: 44px;
padding: 0 22px;
font-size: 0.94rem;
}
.base-button--lg {
min-height: 52px;
padding: 0 28px;
font-size: 1rem;
}
.base-button--primary {
background: var(--bl-accent);
color: #fff;
}
.base-button--primary:hover:not(:disabled) {
background: var(--bl-accent-strong);
transform: translateY(-1px);
box-shadow: 0 10px 18px rgba(0, 113, 227, 0.22);
}
.base-button--secondary {
background: var(--bl-text);
color: #fff;
}
.base-button--secondary:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 10px 20px rgba(29, 29, 31, 0.18);
}
.base-button--ghost {
background: transparent;
color: var(--bl-text);
border: 1px solid var(--bl-border);
}
.base-button--ghost:hover:not(:disabled) {
border-color: var(--bl-border-strong);
background: rgba(255, 255, 255, 0.75);
}
.base-button--soft {
background: rgba(255, 255, 255, 0.7);
color: var(--bl-text);
border: 1px solid rgba(255, 255, 255, 0.7);
backdrop-filter: blur(12px);
}
.base-button--soft:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: var(--bl-shadow-soft);
}
.base-button--block {
width: 100%;
}
.base-button:disabled {
opacity: 0.45;
cursor: not-allowed;
}
</style>

View File

@@ -0,0 +1,62 @@
<script setup>
defineProps({
tag: {
type: String,
default: 'article',
},
padding: {
type: String,
default: 'md',
},
interactive: {
type: Boolean,
default: false,
},
muted: {
type: Boolean,
default: false,
},
});
</script>
<template>
<component :is="tag" :class="['base-card', `base-card--${padding}`, { 'is-interactive': interactive, 'is-muted': muted }]">
<slot />
</component>
</template>
<style scoped>
.base-card {
border-radius: var(--bl-radius-lg);
background: var(--bl-surface);
border: 1px solid rgba(0, 0, 0, 0.04);
box-shadow: var(--bl-shadow-soft);
backdrop-filter: blur(18px);
}
.base-card--sm {
padding: 18px;
}
.base-card--md {
padding: 24px;
}
.base-card--lg {
padding: 30px;
}
.base-card.is-interactive {
cursor: pointer;
transition: var(--bl-transition);
}
.base-card.is-interactive:hover {
transform: translateY(-4px);
box-shadow: var(--bl-shadow-card);
}
.base-card.is-muted {
background: rgba(255, 255, 255, 0.72);
}
</style>

View File

@@ -0,0 +1,163 @@
<script setup>
import { onBeforeUnmount, watch } from 'vue';
const props = defineProps({
modelValue: {
type: Boolean,
default: false,
},
title: {
type: String,
default: '',
},
subtitle: {
type: String,
default: '',
},
width: {
type: String,
default: '760px',
},
});
const emit = defineEmits(['update:modelValue', 'close']);
const close = () => {
emit('update:modelValue', false);
emit('close');
};
const handleKeydown = (event) => {
if (event.key === 'Escape' && props.modelValue) {
close();
}
};
watch(
() => props.modelValue,
(open) => {
document.body.classList.toggle('bl-modal-open', open);
if (open) {
window.addEventListener('keydown', handleKeydown);
} else {
window.removeEventListener('keydown', handleKeydown);
}
},
{ immediate: true },
);
onBeforeUnmount(() => {
document.body.classList.remove('bl-modal-open');
window.removeEventListener('keydown', handleKeydown);
});
</script>
<template>
<teleport to="body">
<transition name="modal-fade">
<div v-if="modelValue" class="base-modal" @click="close">
<div class="base-modal__dialog" :style="{ maxWidth: width }" @click.stop>
<button type="button" class="base-modal__close" aria-label="关闭弹窗" @click="close">×</button>
<header v-if="title || subtitle || $slots.header" class="base-modal__header">
<slot name="header">
<h3 v-if="title">{{ title }}</h3>
<p v-if="subtitle">{{ subtitle }}</p>
</slot>
</header>
<section class="base-modal__body">
<slot />
</section>
<footer v-if="$slots.footer" class="base-modal__footer">
<slot name="footer" />
</footer>
</div>
</div>
</transition>
</teleport>
</template>
<style scoped>
.modal-fade-enter-active,
.modal-fade-leave-active {
transition: opacity 0.25s ease, transform 0.25s ease;
}
.modal-fade-enter-from,
.modal-fade-leave-to {
opacity: 0;
}
.base-modal {
position: fixed;
inset: 0;
z-index: 2000;
padding: 40px 16px;
display: grid;
place-items: center;
background: rgba(15, 23, 42, 0.38);
backdrop-filter: blur(10px);
}
.base-modal__dialog {
position: relative;
width: min(100%, var(--bl-content-width));
max-height: min(90vh, 980px);
overflow: auto;
border-radius: var(--bl-radius-xl);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(250, 250, 252, 0.98));
box-shadow: var(--bl-shadow-modal);
}
.base-modal__close {
position: absolute;
top: 18px;
right: 18px;
width: 38px;
height: 38px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.05);
color: var(--bl-text);
font-size: 1.3rem;
cursor: pointer;
}
.base-modal__header {
padding: 28px 30px 0;
}
.base-modal__header h3,
.base-modal__header p {
margin: 0;
}
.base-modal__header h3 {
font-size: 1.55rem;
font-weight: 700;
}
.base-modal__header p {
margin-top: 8px;
color: var(--bl-text-secondary);
}
.base-modal__body {
padding: 24px 30px 30px;
}
.base-modal__footer {
padding: 0 30px 30px;
}
@media (max-width: 720px) {
.base-modal {
padding: 18px 10px;
}
.base-modal__header,
.base-modal__body,
.base-modal__footer {
padding-left: 18px;
padding-right: 18px;
}
}
</style>

View File

@@ -0,0 +1,59 @@
<script setup>
defineProps({
title: {
type: String,
default: '暂无内容',
},
description: {
type: String,
default: '当前没有可展示的数据。',
},
});
</script>
<template>
<div class="empty-state">
<div class="empty-state__icon"></div>
<h3>{{ title }}</h3>
<p>{{ description }}</p>
<div v-if="$slots.default" class="empty-state__actions">
<slot />
</div>
</div>
</template>
<style scoped>
.empty-state {
padding: 34px 24px;
border-radius: var(--bl-radius-lg);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.85), rgba(250, 250, 252, 0.95));
border: 1px dashed rgba(0, 0, 0, 0.1);
text-align: center;
}
.empty-state__icon {
width: 54px;
height: 54px;
margin: 0 auto 14px;
border-radius: 50%;
background: rgba(0, 113, 227, 0.08);
color: var(--bl-accent);
display: grid;
place-items: center;
font-size: 1.5rem;
}
.empty-state h3,
.empty-state p {
margin: 0;
}
.empty-state p {
margin-top: 8px;
color: var(--bl-text-secondary);
}
.empty-state__actions {
margin-top: 18px;
}
</style>

View File

@@ -0,0 +1,88 @@
<script setup>
const props = defineProps({
label: {
type: String,
default: '',
},
modelValue: {
type: String,
default: 'all',
},
options: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(['update:modelValue']);
</script>
<template>
<div class="filter-group">
<div v-if="label" class="filter-group__label">{{ label }}</div>
<div class="filter-group__tags" role="group" :aria-label="label || '筛选项'">
<button
v-for="option in options"
:key="option.value"
type="button"
:class="['filter-tag', { 'is-active': option.value === modelValue }]"
@click="emit('update:modelValue', option.value)"
>
<span v-if="option.icon" class="filter-tag__icon">{{ option.icon }}</span>
<span>{{ option.label }}</span>
</button>
</div>
</div>
</template>
<style scoped>
.filter-group {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
}
.filter-group__label {
min-width: 72px;
font-size: 0.88rem;
font-weight: 700;
color: var(--bl-text-secondary);
}
.filter-group__tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.filter-tag {
display: inline-flex;
align-items: center;
gap: 6px;
min-height: 34px;
padding: 0 14px;
border-radius: 999px;
background: #fff;
color: var(--bl-text-secondary);
border: 1px solid rgba(0, 0, 0, 0.1);
cursor: pointer;
transition: var(--bl-transition);
}
.filter-tag:hover {
background: #f5f5f7;
color: var(--bl-text);
border-color: rgba(0, 0, 0, 0.2);
}
.filter-tag.is-active {
background: var(--bl-text);
color: #fff;
border-color: var(--bl-text);
}
.filter-tag__icon {
font-size: 0.9em;
}
</style>

View File

@@ -0,0 +1,26 @@
<script setup>
import BaseButton from './BaseButton.vue';
defineProps({
label: {
type: String,
default: '加载更多',
},
});
</script>
<template>
<div class="load-more-wrap">
<BaseButton variant="ghost" size="md">
{{ label }}
</BaseButton>
</div>
</template>
<style scoped>
.load-more-wrap {
display: flex;
justify-content: center;
padding-top: 10px;
}
</style>

View File

@@ -0,0 +1,75 @@
<script setup>
const props = defineProps({
modelValue: {
type: String,
default: '',
},
placeholder: {
type: String,
default: '搜索内容...',
},
ariaLabel: {
type: String,
default: '搜索',
},
});
const emit = defineEmits(['update:modelValue']);
</script>
<template>
<label class="search-box">
<span class="search-box__icon" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none">
<circle cx="11" cy="11" r="6.5" stroke="currentColor" stroke-width="1.8" />
<path d="M16 16L21 21" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" />
</svg>
</span>
<input
:value="props.modelValue"
:placeholder="placeholder"
:aria-label="ariaLabel"
@input="emit('update:modelValue', $event.target.value)"
>
</label>
</template>
<style scoped>
.search-box {
position: relative;
display: block;
width: 100%;
}
.search-box__icon {
position: absolute;
top: 50%;
left: 16px;
width: 18px;
height: 18px;
color: var(--bl-text-tertiary);
transform: translateY(-50%);
}
.search-box__icon svg {
width: 100%;
height: 100%;
}
.search-box input {
width: 100%;
min-height: 46px;
padding: 0 16px 0 46px;
border: 1px solid rgba(0, 0, 0, 0.09);
border-radius: 14px;
background: #f5f5f7;
transition: var(--bl-transition);
}
.search-box input:focus {
outline: none;
background: #fff;
border-color: var(--bl-accent);
box-shadow: 0 0 0 4px rgba(0, 113, 227, 0.08);
}
</style>

View File

@@ -0,0 +1,101 @@
<script setup>
import BaseBadge from '../base/BaseBadge.vue';
import BaseModal from '../base/BaseModal.vue';
import ModalSection from './ModalSection.vue';
defineProps({
modelValue: {
type: Boolean,
default: false,
},
facility: {
type: Object,
required: true,
},
});
defineEmits(['update:modelValue']);
</script>
<template>
<BaseModal :model-value="modelValue" width="780px" @update:model-value="$emit('update:modelValue', $event)">
<template #header>
<div class="detail-header">
<div>
<h3>{{ facility.title }}</h3>
<p>{{ facility.intro }}</p>
</div>
<div class="detail-header__badges">
<BaseBadge tone="accent">{{ facility.type }}</BaseBadge>
<BaseBadge tone="success">{{ facility.dimension }}</BaseBadge>
</div>
</div>
</template>
<div class="detail-grid">
<ModalSection title="位置信息">
<p>{{ facility.location }}</p>
</ModalSection>
<ModalSection title="贡献 / 维护人员">
<div class="detail-pills">
<span v-for="person in facility.contributors" :key="person">{{ person }}</span>
</div>
</ModalSection>
<ModalSection title="使用说明">
<p>{{ facility.instructions }}</p>
</ModalSection>
<ModalSection title="注意事项">
<p>{{ facility.notes }}</p>
</ModalSection>
</div>
</BaseModal>
</template>
<style scoped>
.detail-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
}
.detail-header h3,
.detail-header p {
margin: 0;
}
.detail-header p {
margin-top: 8px;
color: var(--bl-text-secondary);
}
.detail-header__badges {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.detail-grid {
display: grid;
gap: 16px;
}
.detail-pills {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.detail-pills span {
padding: 6px 12px;
border-radius: 999px;
background: #fff;
font-weight: 600;
}
@media (max-width: 720px) {
.detail-header {
flex-direction: column;
}
}
</style>

View File

@@ -0,0 +1,47 @@
<script setup>
defineProps({
title: {
type: String,
required: true,
},
subtitle: {
type: String,
default: '',
},
});
</script>
<template>
<section class="modal-section">
<div class="modal-section__head">
<h4>{{ title }}</h4>
<p v-if="subtitle">{{ subtitle }}</p>
</div>
<div class="modal-section__body">
<slot />
</div>
</section>
</template>
<style scoped>
.modal-section {
padding: 18px 20px;
border-radius: 18px;
background: rgba(245, 245, 247, 0.8);
}
.modal-section__head h4,
.modal-section__head p {
margin: 0;
}
.modal-section__head p {
margin-top: 6px;
color: var(--bl-text-secondary);
font-size: 0.85rem;
}
.modal-section__body {
margin-top: 14px;
}
</style>

View File

@@ -0,0 +1,79 @@
<script setup>
import BaseModal from '../base/BaseModal.vue';
import ModalSection from './ModalSection.vue';
defineProps({
modelValue: {
type: Boolean,
default: false,
},
player: {
type: Object,
required: true,
},
});
defineEmits(['update:modelValue']);
</script>
<template>
<BaseModal :model-value="modelValue" width="760px" @update:model-value="$emit('update:modelValue', $event)">
<template #header>
<div class="player-detail__identity">
<img :src="player.avatar" :alt="`${player.name} avatar`">
<div>
<h3>{{ player.name }}</h3>
<p>{{ player.uuid }}</p>
</div>
</div>
</template>
<div class="player-detail__grid">
<ModalSection title="核心统计">
<div class="player-detail__stats">
<div v-for="stat in player.details" :key="stat.label" class="player-detail__stat-row">
<span>{{ stat.label }}</span>
<strong>{{ stat.value }}</strong>
</div>
</div>
</ModalSection>
</div>
</BaseModal>
</template>
<style scoped>
.player-detail__identity {
display: flex;
align-items: center;
gap: 16px;
}
.player-detail__identity img {
width: 84px;
height: 84px;
border-radius: 14px;
}
.player-detail__identity h3,
.player-detail__identity p {
margin: 0;
}
.player-detail__identity p {
margin-top: 8px;
color: var(--bl-text-secondary);
font-family: monospace;
}
.player-detail__stat-row {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 12px 0;
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
}
.player-detail__stat-row span {
color: var(--bl-text-secondary);
}
</style>

View File

@@ -0,0 +1,118 @@
<script setup>
import BaseModal from '../base/BaseModal.vue';
import BaseButton from '../base/BaseButton.vue';
defineProps({
modelValue: {
type: Boolean,
default: false,
},
summary: {
type: Object,
required: true,
},
});
defineEmits(['update:modelValue']);
</script>
<template>
<BaseModal
:model-value="modelValue"
width="680px"
@update:model-value="$emit('update:modelValue', $event)"
>
<div class="sponsor-modal">
<div class="sponsor-modal__icon"></div>
<h3>{{ summary.title }}</h3>
<p>{{ summary.description }}</p>
<div class="sponsor-modal__stats">
<div>
<small>累计支持</small>
<strong>{{ summary.total }}</strong>
</div>
<div>
<small>活跃赞助者</small>
<strong>{{ summary.supporters }}</strong>
</div>
</div>
<div class="sponsor-modal__actions">
<BaseButton variant="primary">查看赞助方式</BaseButton>
<BaseButton variant="ghost">了解项目用途</BaseButton>
</div>
</div>
</BaseModal>
</template>
<style scoped>
.sponsor-modal {
text-align: center;
}
.sponsor-modal__icon {
width: 76px;
height: 76px;
margin: 0 auto 18px;
border-radius: 24px;
display: grid;
place-items: center;
font-size: 2rem;
color: var(--bl-gold);
background: radial-gradient(circle at 30% 30%, rgba(255, 215, 0, 0.18), rgba(255, 255, 255, 0.9));
}
.sponsor-modal h3,
.sponsor-modal p {
margin: 0;
}
.sponsor-modal p {
max-width: 48ch;
margin: 12px auto 0;
color: var(--bl-text-secondary);
}
.sponsor-modal__stats {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
margin-top: 24px;
}
.sponsor-modal__stats div {
padding: 20px;
border-radius: 18px;
background: rgba(245, 245, 247, 0.8);
}
.sponsor-modal__stats small,
.sponsor-modal__stats strong {
display: block;
}
.sponsor-modal__stats small {
color: var(--bl-text-secondary);
}
.sponsor-modal__stats strong {
margin-top: 8px;
font-size: 1.4rem;
}
.sponsor-modal__actions {
display: flex;
justify-content: center;
gap: 12px;
margin-top: 24px;
}
@media (max-width: 640px) {
.sponsor-modal__stats {
grid-template-columns: 1fr;
}
.sponsor-modal__actions {
flex-direction: column;
}
}
</style>

View File

@@ -0,0 +1,111 @@
<script setup>
import BaseBadge from '../base/BaseBadge.vue';
import BaseModal from '../base/BaseModal.vue';
import ModalSection from './ModalSection.vue';
defineProps({
modelValue: {
type: Boolean,
default: false,
},
town: {
type: Object,
required: true,
},
});
defineEmits(['update:modelValue']);
</script>
<template>
<BaseModal :model-value="modelValue" width="820px" @update:model-value="$emit('update:modelValue', $event)">
<template #header>
<div class="town-detail__banner" :style="{ backgroundImage: `url(${town.image})` }"></div>
<div class="town-detail__header">
<div>
<h3>{{ town.title }}</h3>
<p>{{ town.intro }}</p>
</div>
<div class="town-detail__badges">
<BaseBadge tone="accent">{{ town.scale }}</BaseBadge>
<BaseBadge tone="success">{{ town.recruitment }}</BaseBadge>
</div>
</div>
</template>
<div class="town-detail__grid">
<ModalSection title="位置信息">
<p>{{ town.location }}</p>
</ModalSection>
<ModalSection title="创始人">
<div class="town-detail__list">
<span v-for="item in town.founders" :key="item">{{ item }}</span>
</div>
</ModalSection>
<ModalSection title="主要成员">
<div class="town-detail__list">
<span v-for="item in town.members" :key="item">{{ item }}</span>
</div>
</ModalSection>
<ModalSection title="城镇介绍">
<p>{{ town.description }}</p>
</ModalSection>
</div>
</BaseModal>
</template>
<style scoped>
.town-detail__banner {
height: 180px;
margin: -28px -30px 18px;
background:
linear-gradient(to top, rgba(15, 23, 42, 0.3), transparent 55%),
center/cover no-repeat;
}
.town-detail__header {
display: flex;
justify-content: space-between;
gap: 16px;
}
.town-detail__header h3,
.town-detail__header p {
margin: 0;
}
.town-detail__header p {
margin-top: 8px;
color: var(--bl-text-secondary);
}
.town-detail__badges,
.town-detail__list {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.town-detail__list span {
padding: 6px 12px;
border-radius: 999px;
background: #fff;
font-weight: 600;
}
.town-detail__grid {
display: grid;
gap: 16px;
}
@media (max-width: 720px) {
.town-detail__banner {
margin-left: -18px;
margin-right: -18px;
}
.town-detail__header {
flex-direction: column;
}
}
</style>

33
src/components/index.js Normal file
View File

@@ -0,0 +1,33 @@
export { default as SiteNavbar } from './layout/SiteNavbar.vue';
export { default as MobileNavDrawer } from './layout/MobileNavDrawer.vue';
export { default as PageHero } from './layout/PageHero.vue';
export { default as SiteFooter } from './layout/SiteFooter.vue';
export { default as BaseButton } from './base/BaseButton.vue';
export { default as BaseCard } from './base/BaseCard.vue';
export { default as BaseBadge } from './base/BaseBadge.vue';
export { default as BaseModal } from './base/BaseModal.vue';
export { default as SearchBox } from './base/SearchBox.vue';
export { default as FilterTagGroup } from './base/FilterTagGroup.vue';
export { default as EmptyState } from './base/EmptyState.vue';
export { default as LoadMoreButton } from './base/LoadMoreButton.vue';
export { default as FilterPanel } from './shared/FilterPanel.vue';
export { default as FacilityCard } from './shared/FacilityCard.vue';
export { default as TownCard } from './shared/TownCard.vue';
export { default as AnnouncementCard } from './shared/AnnouncementCard.vue';
export { default as AnnouncementTimeline } from './shared/AnnouncementTimeline.vue';
export { default as LeaderboardCard } from './shared/LeaderboardCard.vue';
export { default as PlayerCard } from './shared/PlayerCard.vue';
export { default as DonationCard } from './shared/DonationCard.vue';
export { default as FeatureBentoCard } from './shared/FeatureBentoCard.vue';
export { default as FeatureBentoGrid } from './shared/FeatureBentoGrid.vue';
export { default as JoinWizard } from './shared/JoinWizard.vue';
export { default as DeviceCard } from './shared/DeviceCard.vue';
export { default as PlaystyleCard } from './shared/PlaystyleCard.vue';
export { default as ModalSection } from './detail/ModalSection.vue';
export { default as FacilityDetailModal } from './detail/FacilityDetailModal.vue';
export { default as TownDetailModal } from './detail/TownDetailModal.vue';
export { default as PlayerDetailModal } from './detail/PlayerDetailModal.vue';
export { default as SponsorModal } from './detail/SponsorModal.vue';

View File

@@ -0,0 +1,144 @@
<script setup>
defineProps({
open: {
type: Boolean,
default: false,
},
items: {
type: Array,
default: () => [],
},
ctaLabel: {
type: String,
default: '加入游戏',
},
ctaHref: {
type: String,
default: '/join',
},
});
const emit = defineEmits(['close']);
</script>
<template>
<transition name="drawer-fade">
<div v-if="open" class="mobile-drawer-mask" @click="emit('close')">
<aside class="mobile-drawer" @click.stop>
<div class="mobile-drawer__header">
<p>站点导航</p>
<button type="button" class="mobile-drawer__close" aria-label="关闭菜单" @click="emit('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>
</nav>
<a class="mobile-drawer__cta" :href="ctaHref" @click="emit('close')">{{ ctaLabel }}</a>
</aside>
</div>
</transition>
</template>
<style scoped>
.drawer-fade-enter-active,
.drawer-fade-leave-active {
transition: opacity 0.25s ease;
}
.drawer-fade-enter-from,
.drawer-fade-leave-to {
opacity: 0;
}
.mobile-drawer-mask {
position: fixed;
inset: 0;
z-index: 1200;
display: flex;
justify-content: flex-end;
background: rgba(15, 23, 42, 0.28);
backdrop-filter: blur(12px);
}
.mobile-drawer {
width: min(360px, 100%);
height: 100%;
padding: 24px 20px 28px;
background: rgba(255, 255, 255, 0.96);
box-shadow: -20px 0 60px rgba(15, 23, 42, 0.16);
display: flex;
flex-direction: column;
}
.mobile-drawer__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
}
.mobile-drawer__header p {
margin: 0;
font-size: 0.9rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--bl-text-tertiary);
}
.mobile-drawer__close {
width: 40px;
height: 40px;
border-radius: 50%;
background: var(--bl-surface-muted);
font-size: 1.4rem;
cursor: pointer;
}
.mobile-drawer__links {
display: grid;
gap: 10px;
}
.mobile-drawer__link {
display: flex;
flex-direction: column;
gap: 2px;
padding: 14px 16px;
border-radius: var(--bl-radius-md);
background: #fff;
text-decoration: none;
border: 1px solid rgba(0, 0, 0, 0.05);
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.03);
}
.mobile-drawer__link span {
font-weight: 600;
}
.mobile-drawer__link small {
color: var(--bl-text-secondary);
}
.mobile-drawer__cta {
margin-top: auto;
display: inline-flex;
justify-content: center;
align-items: center;
min-height: 48px;
border-radius: 999px;
background: var(--bl-accent);
color: #fff;
text-decoration: none;
font-weight: 700;
}
</style>

View File

@@ -0,0 +1,112 @@
<script setup>
defineProps({
eyebrow: {
type: String,
default: '白鹿原组件库',
},
title: {
type: String,
required: true,
},
subtitle: {
type: String,
default: '',
},
backgroundImage: {
type: String,
default: 'https://img.lunadeer.cn/i/2025/11/26/69267755e14e3.png',
},
compact: {
type: Boolean,
default: false,
},
});
</script>
<template>
<section
:class="['page-hero', { 'page-hero--compact': compact }]"
:style="{ backgroundImage: `linear-gradient(rgba(0, 0, 0, 0.38), rgba(0, 0, 0, 0.34)), url(${backgroundImage})` }"
>
<div class="page-hero__inner bl-shell">
<div class="page-hero__copy">
<p class="page-hero__eyebrow">{{ eyebrow }}</p>
<h1>{{ title }}</h1>
<p v-if="subtitle" class="page-hero__subtitle">{{ subtitle }}</p>
</div>
<div v-if="$slots.default" class="page-hero__actions">
<slot />
</div>
</div>
</section>
</template>
<style scoped>
.page-hero {
min-height: 42vh;
padding-top: calc(var(--bl-header-height) + 40px);
padding-bottom: 56px;
display: flex;
align-items: flex-end;
background-size: cover;
background-position: center;
color: #fff;
}
.page-hero--compact {
min-height: 34vh;
}
.page-hero__inner {
display: flex;
justify-content: space-between;
align-items: flex-end;
gap: 20px;
}
.page-hero__copy {
max-width: 760px;
}
.page-hero__eyebrow {
margin: 0 0 12px;
font-size: 0.82rem;
font-weight: 700;
letter-spacing: 0.18em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.78);
}
.page-hero h1 {
margin: 0;
font-size: clamp(2.2rem, 5.2vw, 4.4rem);
line-height: 1.02;
letter-spacing: -0.04em;
text-shadow: 0 8px 30px rgba(0, 0, 0, 0.24);
}
.page-hero__subtitle {
max-width: 720px;
margin: 14px 0 0;
font-size: clamp(1rem, 1.8vw, 1.4rem);
color: rgba(255, 255, 255, 0.9);
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.28);
}
.page-hero__actions {
display: flex;
align-items: flex-end;
}
@media (max-width: 840px) {
.page-hero {
min-height: 46vh;
padding-bottom: 32px;
}
.page-hero__inner {
flex-direction: column;
align-items: flex-start;
}
}
</style>

View File

@@ -0,0 +1,86 @@
<script setup>
const props = defineProps({
brand: {
type: String,
default: '白鹿原',
},
year: {
type: Number,
default: new Date().getFullYear(),
},
});
const footerLinks = [
{ label: '文档', href: '/doc.html' },
{ label: '地图', href: '/map.html' },
{ label: '赞助', href: '/sponsor.html' },
];
</script>
<template>
<footer class="site-footer">
<div class="site-footer__inner bl-shell">
<div>
<p class="site-footer__brand">{{ brand }}</p>
<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>
</nav>
</div>
</footer>
</template>
<style scoped>
.site-footer {
margin-top: 80px;
padding: 28px 0 36px;
border-top: 1px solid rgba(0, 0, 0, 0.06);
background: rgba(255, 255, 255, 0.55);
backdrop-filter: blur(16px);
}
.site-footer__inner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
}
.site-footer__brand,
.site-footer__copy {
margin: 0;
}
.site-footer__brand {
font-size: 1.1rem;
font-weight: 700;
}
.site-footer__copy {
margin-top: 6px;
color: var(--bl-text-secondary);
}
.site-footer__links {
display: flex;
flex-wrap: wrap;
gap: 18px;
}
.site-footer__links a {
color: var(--bl-text-secondary);
text-decoration: none;
}
.site-footer__links a:hover {
color: var(--bl-text);
}
@media (max-width: 720px) {
.site-footer__inner {
flex-direction: column;
align-items: flex-start;
}
}
</style>

View File

@@ -0,0 +1,189 @@
<script setup>
import { ref } from 'vue';
import MobileNavDrawer from './MobileNavDrawer.vue';
const props = defineProps({
items: {
type: Array,
default: () => [],
},
activePath: {
type: String,
default: '/',
},
logoSrc: {
type: String,
default: 'https://img.lunadeer.cn/i/2024/04/22/6625ce6c8ddc1.png',
},
logoAlt: {
type: String,
default: '白鹿原 Minecraft 服务器 Logo',
},
ctaLabel: {
type: String,
default: '加入游戏',
},
ctaHref: {
type: String,
default: '/join',
},
});
const mobileOpen = ref(false);
const isActive = (href) => href === props.activePath;
</script>
<template>
<header class="site-navbar">
<div class="site-navbar__inner bl-shell">
<button
type="button"
class="site-navbar__toggle"
aria-label="打开菜单"
@click="mobileOpen = true"
>
<span></span>
<span></span>
<span></span>
</button>
<a class="site-navbar__logo" href="/">
<img :src="logoSrc" :alt="logoAlt">
</a>
<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>
</nav>
<a class="site-navbar__cta" :href="ctaHref">{{ ctaLabel }}</a>
</div>
</header>
<MobileNavDrawer
:open="mobileOpen"
:items="items"
:cta-label="ctaLabel"
:cta-href="ctaHref"
@close="mobileOpen = false"
/>
</template>
<style scoped>
.site-navbar {
position: fixed;
inset: 0 0 auto;
z-index: 1100;
height: var(--bl-header-height);
background: rgba(255, 255, 255, 0.8);
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
backdrop-filter: blur(20px);
}
.site-navbar__inner {
height: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.site-navbar__toggle {
display: none;
width: 40px;
height: 40px;
border-radius: 50%;
background: transparent;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 4px;
cursor: pointer;
}
.site-navbar__toggle span {
width: 16px;
height: 1.5px;
background: var(--bl-text);
border-radius: 999px;
}
.site-navbar__logo img {
width: auto;
height: 32px;
}
.site-navbar__links {
display: flex;
align-items: center;
gap: 22px;
margin-left: auto;
margin-right: 8px;
}
.site-navbar__link {
position: relative;
font-size: 0.82rem;
color: rgba(29, 29, 31, 0.82);
text-decoration: none;
transition: color 0.2s ease;
}
.site-navbar__link:hover,
.site-navbar__link.is-active {
color: var(--bl-text);
}
.site-navbar__link.is-active::after {
content: '';
position: absolute;
left: 0;
right: 0;
bottom: -12px;
height: 2px;
border-radius: 999px;
background: var(--bl-text);
}
.site-navbar__cta {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 32px;
padding: 0 16px;
border-radius: 999px;
background: var(--bl-accent);
color: #fff;
text-decoration: none;
font-size: 0.82rem;
font-weight: 600;
transition: var(--bl-transition);
}
.site-navbar__cta:hover {
background: var(--bl-accent-strong);
transform: translateY(-1px);
}
@media (max-width: 860px) {
.site-navbar__toggle {
display: inline-flex;
}
.site-navbar__links,
.site-navbar__cta {
display: none;
}
.site-navbar__inner {
justify-content: space-between;
}
}
</style>

View File

@@ -0,0 +1,129 @@
<script setup>
import { ref } from 'vue';
import BaseBadge from '../base/BaseBadge.vue';
import BaseCard from '../base/BaseCard.vue';
const props = defineProps({
announcement: {
type: Object,
required: true,
},
expanded: {
type: Boolean,
default: false,
},
});
const open = ref(props.expanded);
const toneMap = {
activity: 'success',
maintenance: 'warning',
other: 'purple',
};
</script>
<template>
<BaseCard :class="['announcement-card', { 'is-expanded': open }]" padding="sm">
<button type="button" class="announcement-card__summary" @click="open = !open">
<div class="announcement-card__main">
<div class="announcement-card__top">
<BaseBadge :tone="toneMap[announcement.category] || 'neutral'">
{{ announcement.categoryLabel }}
</BaseBadge>
<span class="announcement-card__time">{{ announcement.time }}</span>
</div>
<h3>{{ announcement.title }}</h3>
<p>{{ announcement.intro }}</p>
</div>
<span class="announcement-card__caret" :class="{ 'is-open': open }"></span>
</button>
<div v-if="open" class="announcement-card__content">
<div v-for="block in announcement.content" :key="block.title" class="announcement-card__block">
<h4>{{ block.title }}</h4>
<p>{{ block.body }}</p>
</div>
</div>
</BaseCard>
</template>
<style scoped>
.announcement-card {
overflow: hidden;
}
.announcement-card__summary {
width: 100%;
padding: 18px 22px;
display: flex;
align-items: flex-start;
gap: 16px;
background: transparent;
cursor: pointer;
text-align: left;
}
.announcement-card.is-expanded .announcement-card__summary {
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
background: linear-gradient(to bottom, #fff, #fafafa);
}
.announcement-card__main {
flex: 1;
min-width: 0;
}
.announcement-card__top {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 6px;
}
.announcement-card__time {
font-size: 0.82rem;
color: var(--bl-text-tertiary);
}
.announcement-card h3,
.announcement-card p,
.announcement-card h4 {
margin: 0;
}
.announcement-card h3 {
font-size: 1.08rem;
}
.announcement-card p {
color: var(--bl-text-secondary);
}
.announcement-card__caret {
color: var(--bl-text-secondary);
transition: transform 0.2s ease;
}
.announcement-card__caret.is-open {
transform: rotate(180deg);
}
.announcement-card__content {
display: grid;
gap: 14px;
padding: 18px 22px 22px;
}
.announcement-card__block {
padding: 14px 16px;
border-radius: var(--bl-radius-md);
background: rgba(245, 245, 247, 0.72);
}
.announcement-card__block h4 {
margin-bottom: 6px;
font-size: 0.94rem;
}
</style>

View File

@@ -0,0 +1,53 @@
<script setup>
import AnnouncementCard from './AnnouncementCard.vue';
defineProps({
items: {
type: Array,
default: () => [],
},
});
</script>
<template>
<div class="announcement-timeline">
<article v-for="item in items" :key="item.id" class="announcement-timeline__item">
<AnnouncementCard :announcement="item" />
</article>
</div>
</template>
<style scoped>
.announcement-timeline {
position: relative;
display: grid;
gap: 22px;
padding-left: 32px;
}
.announcement-timeline::before {
content: '';
position: absolute;
top: 0;
bottom: 0;
left: 7px;
width: 2px;
background: linear-gradient(to bottom, var(--bl-accent), rgba(0, 113, 227, 0.08));
}
.announcement-timeline__item {
position: relative;
}
.announcement-timeline__item::before {
content: '';
position: absolute;
left: -32px;
top: 24px;
width: 16px;
height: 16px;
border-radius: 50%;
background: #fff;
border: 3px solid var(--bl-accent);
}
</style>

View File

@@ -0,0 +1,66 @@
<script setup>
defineProps({
device: {
type: Object,
required: true,
},
selected: {
type: Boolean,
default: false,
},
});
</script>
<template>
<button type="button" :class="['device-card', { 'is-selected': selected }]">
<span class="device-card__icon">{{ device.icon }}</span>
<strong>{{ device.name }}</strong>
<small>{{ device.description }}</small>
</button>
</template>
<style scoped>
.device-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
min-height: 180px;
padding: 26px 18px;
border-radius: 18px;
border: 2px solid #f5f5f7;
background: #fff;
cursor: pointer;
transition: var(--bl-transition);
text-align: center;
}
.device-card:hover,
.device-card.is-selected {
border-color: var(--bl-accent);
transform: translateY(-2px);
}
.device-card.is-selected {
background: rgba(0, 113, 227, 0.03);
}
.device-card__icon {
width: 60px;
height: 60px;
border-radius: 50%;
display: grid;
place-items: center;
background: #f5f5f7;
font-size: 1.4rem;
}
.device-card.is-selected .device-card__icon {
background: var(--bl-accent);
color: #fff;
}
.device-card small {
color: var(--bl-text-secondary);
}
</style>

View File

@@ -0,0 +1,71 @@
<script setup>
import BaseBadge from '../base/BaseBadge.vue';
import BaseCard from '../base/BaseCard.vue';
defineProps({
donation: {
type: Object,
required: true,
},
});
</script>
<template>
<BaseCard class="donation-card" padding="md" interactive>
<div class="donation-card__top">
<div>
<h3>{{ donation.name }}</h3>
<p>{{ donation.message }}</p>
</div>
<BaseBadge tone="success">{{ donation.project }}</BaseBadge>
</div>
<div class="donation-card__footer">
<strong>{{ donation.amount }}</strong>
<span>{{ donation.time }}</span>
</div>
</BaseCard>
</template>
<style scoped>
.donation-card {
display: grid;
gap: 18px;
}
.donation-card__top {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
}
.donation-card__top h3,
.donation-card__top p,
.donation-card__footer strong,
.donation-card__footer span {
margin: 0;
}
.donation-card__top p {
margin-top: 6px;
color: var(--bl-text-secondary);
}
.donation-card__footer {
display: flex;
justify-content: space-between;
align-items: baseline;
padding-top: 14px;
border-top: 1px solid rgba(0, 0, 0, 0.05);
}
.donation-card__footer strong {
font-size: 1.35rem;
color: var(--bl-green);
}
.donation-card__footer span {
color: var(--bl-text-tertiary);
font-size: 0.85rem;
}
</style>

View File

@@ -0,0 +1,79 @@
<script setup>
import BaseBadge from '../base/BaseBadge.vue';
import BaseCard from '../base/BaseCard.vue';
defineProps({
facility: {
type: Object,
required: true,
},
});
const toneMap = {
online: 'success',
maintenance: 'warning',
offline: 'danger',
};
</script>
<template>
<BaseCard class="facility-card" padding="md" interactive>
<div class="facility-card__header">
<h3>{{ facility.title }}</h3>
<BaseBadge :tone="toneMap[facility.status] || 'neutral'">
{{ facility.statusLabel }}
</BaseBadge>
</div>
<p class="facility-card__intro">{{ facility.intro }}</p>
<div class="facility-card__meta">
<span v-for="meta in facility.meta" :key="meta" class="facility-card__tag">{{ meta }}</span>
</div>
</BaseCard>
</template>
<style scoped>
.facility-card {
display: flex;
flex-direction: column;
gap: 16px;
min-height: 230px;
}
.facility-card__header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
}
.facility-card__header h3 {
margin: 0;
font-size: 1.1rem;
line-height: 1.35;
}
.facility-card__intro {
margin: 0;
color: var(--bl-text-secondary);
flex: 1;
}
.facility-card__meta {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding-top: 16px;
border-top: 1px solid rgba(0, 0, 0, 0.05);
}
.facility-card__tag {
padding: 4px 10px;
border-radius: 999px;
background: #f5f5f7;
color: var(--bl-text-secondary);
font-size: 0.76rem;
font-weight: 600;
}
</style>

View File

@@ -0,0 +1,71 @@
<script setup>
defineProps({
item: {
type: Object,
required: true,
},
});
</script>
<template>
<article :class="['feature-bento-card', `feature-bento-card--${item.size}`]" :style="{ background: item.background }">
<div class="feature-bento-card__overlay"></div>
<div class="feature-bento-card__content">
<div class="feature-bento-card__icon">{{ item.icon }}</div>
<h3>{{ item.title }}</h3>
<p>{{ item.description }}</p>
</div>
</article>
</template>
<style scoped>
.feature-bento-card {
position: relative;
overflow: hidden;
border-radius: 24px;
min-height: 220px;
color: #fff;
box-shadow: var(--bl-shadow-card);
}
.feature-bento-card--small {
min-height: 180px;
}
.feature-bento-card__overlay {
position: absolute;
inset: 0;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.08), rgba(15, 23, 42, 0.3));
}
.feature-bento-card__content {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
justify-content: flex-end;
gap: 8px;
height: 100%;
padding: 24px;
}
.feature-bento-card__icon {
width: 42px;
height: 42px;
border-radius: 14px;
display: grid;
place-items: center;
background: rgba(255, 255, 255, 0.14);
font-size: 1.2rem;
}
.feature-bento-card h3,
.feature-bento-card p {
margin: 0;
}
.feature-bento-card p {
max-width: 28ch;
color: rgba(255, 255, 255, 0.82);
}
</style>

View File

@@ -0,0 +1,51 @@
<script setup>
import FeatureBentoCard from './FeatureBentoCard.vue';
defineProps({
items: {
type: Array,
default: () => [],
},
});
</script>
<template>
<div class="feature-bento-grid">
<FeatureBentoCard v-for="item in items" :key="item.title" :item="item" />
</div>
</template>
<style scoped>
.feature-bento-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 18px;
}
.feature-bento-grid :deep(.feature-bento-card--large) {
grid-column: span 2;
grid-row: span 2;
}
.feature-bento-grid :deep(.feature-bento-card--medium) {
grid-column: span 2;
}
@media (max-width: 960px) {
.feature-bento-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.feature-bento-grid :deep(.feature-bento-card--large),
.feature-bento-grid :deep(.feature-bento-card--medium) {
grid-column: span 1;
grid-row: span 1;
}
}
@media (max-width: 640px) {
.feature-bento-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,108 @@
<script setup>
import BaseButton from '../base/BaseButton.vue';
import BaseCard from '../base/BaseCard.vue';
import FilterTagGroup from '../base/FilterTagGroup.vue';
import SearchBox from '../base/SearchBox.vue';
defineProps({
title: {
type: String,
default: '筛选内容',
},
searchValue: {
type: String,
default: '',
},
searchPlaceholder: {
type: String,
default: '搜索标题或简介...',
},
filters: {
type: Array,
default: () => [],
},
actionLabel: {
type: String,
default: '',
},
});
const emit = defineEmits(['update:searchValue', 'change-filter', 'action']);
</script>
<template>
<BaseCard class="filter-panel" padding="lg">
<div class="filter-panel__head">
<div class="filter-panel__title">
<h3>{{ title }}</h3>
</div>
<div class="filter-panel__tools">
<BaseButton v-if="actionLabel" variant="primary" size="sm" @click="emit('action')">
{{ actionLabel }}
</BaseButton>
<SearchBox
:model-value="searchValue"
:placeholder="searchPlaceholder"
@update:model-value="emit('update:searchValue', $event)"
/>
</div>
</div>
<div class="filter-panel__body">
<FilterTagGroup
v-for="filter in filters"
:key="filter.key"
:label="filter.label"
:options="filter.options"
:model-value="filter.modelValue"
@update:model-value="emit('change-filter', { key: filter.key, value: $event })"
/>
</div>
</BaseCard>
</template>
<style scoped>
.filter-panel {
display: grid;
gap: 24px;
}
.filter-panel__head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 20px;
flex-wrap: wrap;
}
.filter-panel__title h3 {
margin: 0;
font-size: 1.5rem;
}
.filter-panel__tools {
display: flex;
align-items: center;
gap: 12px;
width: min(520px, 100%);
}
.filter-panel__tools :deep(.search-box) {
flex: 1;
}
.filter-panel__body {
display: grid;
gap: 16px;
padding-top: 20px;
border-top: 1px solid rgba(0, 0, 0, 0.05);
}
@media (max-width: 720px) {
.filter-panel__tools {
flex-direction: column;
align-items: stretch;
}
}
</style>

View File

@@ -0,0 +1,276 @@
<script setup>
import { computed, ref } from 'vue';
import BaseButton from '../base/BaseButton.vue';
import BaseCard from '../base/BaseCard.vue';
import DeviceCard from './DeviceCard.vue';
import PlaystyleCard from './PlaystyleCard.vue';
const props = defineProps({
devices: {
type: Array,
default: () => [],
},
playstyles: {
type: Array,
default: () => [],
},
});
const step = ref(0);
const selectedDevice = ref(props.devices[0]?.id ?? null);
const selectedStyle = ref(props.playstyles[0]?.id ?? null);
const steps = [
{ label: '选择设备', subtitle: '确认你的游玩平台' },
{ label: '安装与登录', subtitle: '跟随图文教程进入服务器' },
{ label: '偏好路线', subtitle: '选择你的玩法方向' },
];
const canNext = computed(() => {
if (step.value === 0) {
return Boolean(selectedDevice.value);
}
if (step.value === 2) {
return Boolean(selectedStyle.value);
}
return true;
});
</script>
<template>
<BaseCard class="join-wizard" padding="sm">
<aside class="join-wizard__sidebar">
<div class="join-wizard__steps">
<div
v-for="(item, index) in steps"
:key="item.label"
:class="['join-wizard__step', { 'is-active': index === step, 'is-complete': index < step }]"
>
<strong>{{ item.label }}</strong>
<small>{{ item.subtitle }}</small>
</div>
</div>
</aside>
<section class="join-wizard__content">
<div v-if="step === 0" class="join-wizard__panel">
<header>
<h3>你准备用什么设备加入</h3>
<p>映射自旧站 Join Wizard 的第一步卡片选择模式</p>
</header>
<div class="join-wizard__device-grid">
<DeviceCard
v-for="device in devices"
:key="device.id"
:device="device"
:selected="selectedDevice === device.id"
@click="selectedDevice = device.id"
/>
</div>
</div>
<div v-else-if="step === 1" class="join-wizard__panel join-wizard__panel--guide">
<header>
<h3>安装与登录</h3>
<p>这里保留了旧站教程页的大卡片叙事结构但已整理为可组合步骤</p>
</header>
<div class="join-wizard__guide-card">
<span class="bl-demo-chip">基岩 / Java 双端</span>
<strong>下载版本匹配的客户端后复制服务器地址并登录白名单账户</strong>
<p>页面迁移阶段可继续拆分成安装步骤卡FAQ 卡和注意事项卡</p>
</div>
</div>
<div v-else class="join-wizard__panel">
<header>
<h3>你更偏向哪种玩法</h3>
<p>映射自旧站 Join 页面里的 playstyle-card 选择区</p>
</header>
<div class="join-wizard__style-grid">
<PlaystyleCard
v-for="style in playstyles"
:key="style.id"
:option="style"
:selected="selectedStyle === style.id"
@click="selectedStyle = style.id"
/>
</div>
</div>
<footer class="join-wizard__footer">
<BaseButton variant="ghost" :disabled="step === 0" @click="step -= 1">
上一步
</BaseButton>
<div class="join-wizard__footer-actions">
<BaseButton v-if="step < steps.length - 1" :disabled="!canNext" @click="step += 1">
下一步
</BaseButton>
<BaseButton v-else variant="secondary">查看完整加入流程</BaseButton>
</div>
</footer>
</section>
</BaseCard>
</template>
<style scoped>
.join-wizard {
padding: 0;
display: flex;
overflow: hidden;
min-height: 620px;
}
.join-wizard__sidebar {
width: 280px;
padding: 54px 38px;
background: #f5f5f7;
border-right: 1px solid rgba(0, 0, 0, 0.05);
}
.join-wizard__steps {
display: flex;
flex-direction: column;
gap: 34px;
}
.join-wizard__step {
position: relative;
padding-left: 18px;
display: grid;
gap: 4px;
color: var(--bl-text-tertiary);
opacity: 0.7;
}
.join-wizard__step::before {
content: '';
position: absolute;
left: 0;
top: 6px;
width: 4px;
height: 4px;
border-radius: 999px;
background: #d2d2d7;
}
.join-wizard__step.is-active,
.join-wizard__step.is-complete {
color: var(--bl-text);
opacity: 1;
}
.join-wizard__step.is-active::before {
height: 24px;
border-radius: 999px;
background: var(--bl-accent);
}
.join-wizard__step.is-complete::before {
background: var(--bl-green);
}
.join-wizard__content {
flex: 1;
display: flex;
flex-direction: column;
padding: 56px;
}
.join-wizard__panel {
display: grid;
gap: 26px;
flex: 1;
}
.join-wizard__panel header h3,
.join-wizard__panel header p,
.join-wizard__guide-card strong,
.join-wizard__guide-card p {
margin: 0;
}
.join-wizard__panel header p,
.join-wizard__guide-card p {
margin-top: 8px;
color: var(--bl-text-secondary);
}
.join-wizard__device-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 20px;
}
.join-wizard__guide-card {
display: grid;
gap: 14px;
padding: 28px;
border-radius: 24px;
background: linear-gradient(180deg, rgba(255, 255, 255, 1), rgba(247, 248, 250, 1));
box-shadow: var(--bl-shadow-soft);
}
.join-wizard__style-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.join-wizard__footer {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
padding-top: 32px;
margin-top: auto;
border-top: 1px solid rgba(0, 0, 0, 0.05);
}
.join-wizard__footer-actions {
display: flex;
gap: 12px;
}
@media (max-width: 1000px) {
.join-wizard {
flex-direction: column;
}
.join-wizard__sidebar {
width: auto;
padding: 28px 24px 12px;
border-right: 0;
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
}
.join-wizard__steps {
gap: 18px;
}
.join-wizard__content {
padding: 28px 24px;
}
.join-wizard__device-grid,
.join-wizard__style-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 640px) {
.join-wizard__footer {
flex-direction: column;
align-items: stretch;
}
.join-wizard__footer-actions {
justify-content: stretch;
}
.join-wizard__footer-actions :deep(.base-button) {
width: 100%;
}
}
</style>

View File

@@ -0,0 +1,125 @@
<script setup>
import BaseCard from '../base/BaseCard.vue';
defineProps({
board: {
type: Object,
required: true,
},
});
</script>
<template>
<BaseCard class="leaderboard-card" padding="md">
<div class="leaderboard-card__header" :style="{ borderTopColor: board.color }">
<div class="leaderboard-card__icon">{{ board.icon }}</div>
<div>
<h3>{{ board.title }}</h3>
<p>{{ board.subtitle }}</p>
</div>
</div>
<div class="leaderboard-card__champion">
<strong>{{ board.top.name }}</strong>
<span>{{ board.top.value }}</span>
</div>
<ol class="leaderboard-card__list">
<li v-for="entry in board.entries" :key="entry.rank">
<span class="leaderboard-card__rank">{{ entry.rank }}</span>
<span class="leaderboard-card__name">{{ entry.name }}</span>
<strong>{{ entry.value }}</strong>
</li>
</ol>
</BaseCard>
</template>
<style scoped>
.leaderboard-card {
padding-top: 0;
overflow: hidden;
}
.leaderboard-card__header {
display: flex;
align-items: center;
gap: 12px;
margin: 0 -24px 20px;
padding: 20px 24px 0;
border-top: 4px solid var(--bl-gold);
}
.leaderboard-card__icon {
width: 42px;
height: 42px;
border-radius: 12px;
display: grid;
place-items: center;
background: var(--bl-bg);
font-size: 1.2rem;
}
.leaderboard-card__header h3,
.leaderboard-card__header p {
margin: 0;
}
.leaderboard-card__header p {
color: var(--bl-text-secondary);
font-size: 0.88rem;
}
.leaderboard-card__champion {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding-bottom: 18px;
margin-bottom: 18px;
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
}
.leaderboard-card__champion strong {
font-size: 1.05rem;
}
.leaderboard-card__champion span {
font-size: 1.4rem;
font-weight: 800;
color: var(--bl-accent);
}
.leaderboard-card__list {
display: grid;
gap: 10px;
margin: 0;
padding: 0;
list-style: none;
}
.leaderboard-card__list li {
display: grid;
grid-template-columns: 28px 1fr auto;
gap: 10px;
align-items: center;
padding-bottom: 10px;
border-bottom: 1px dashed rgba(0, 0, 0, 0.08);
}
.leaderboard-card__rank {
width: 24px;
height: 24px;
border-radius: 50%;
background: #eee;
display: grid;
place-items: center;
font-size: 0.78rem;
font-weight: 800;
}
.leaderboard-card__name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>

View File

@@ -0,0 +1,69 @@
<script setup>
import BaseCard from '../base/BaseCard.vue';
defineProps({
player: {
type: Object,
required: true,
},
});
</script>
<template>
<BaseCard class="player-card" padding="md" interactive>
<img :src="player.avatar" :alt="`${player.name} avatar`">
<h3>{{ player.name }}</h3>
<p class="player-card__uuid">{{ player.uuid }}</p>
<div class="player-card__stats">
<span v-for="stat in player.highlights" :key="stat.label">
<strong>{{ stat.value }}</strong>
<small>{{ stat.label }}</small>
</span>
</div>
</BaseCard>
</template>
<style scoped>
.player-card {
text-align: center;
}
.player-card img {
width: 80px;
height: 80px;
margin: 0 auto 14px;
border-radius: 12px;
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12);
}
.player-card h3,
.player-card__uuid {
margin: 0;
}
.player-card__uuid {
margin-top: 6px;
color: var(--bl-text-secondary);
font-family: monospace;
font-size: 0.78rem;
}
.player-card__stats {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid rgba(0, 0, 0, 0.05);
}
.player-card__stats span {
display: flex;
flex-direction: column;
gap: 2px;
}
.player-card__stats small {
color: var(--bl-text-secondary);
}
</style>

View File

@@ -0,0 +1,64 @@
<script setup>
defineProps({
option: {
type: Object,
required: true,
},
selected: {
type: Boolean,
default: false,
},
});
</script>
<template>
<button type="button" :class="['playstyle-card', { 'is-selected': selected }]">
<div class="playstyle-card__header">
<span class="playstyle-card__icon">{{ option.icon }}</span>
<strong>{{ option.name }}</strong>
</div>
<p>{{ option.description }}</p>
</button>
</template>
<style scoped>
.playstyle-card {
display: grid;
gap: 14px;
padding: 20px;
border-radius: 18px;
border: 1px solid rgba(0, 0, 0, 0.08);
background: #fff;
cursor: pointer;
transition: var(--bl-transition);
text-align: left;
}
.playstyle-card:hover,
.playstyle-card.is-selected {
transform: translateY(-2px);
border-color: rgba(0, 113, 227, 0.35);
box-shadow: var(--bl-shadow-soft);
}
.playstyle-card__header {
display: flex;
align-items: center;
gap: 12px;
}
.playstyle-card__icon {
width: 42px;
height: 42px;
border-radius: 14px;
display: grid;
place-items: center;
background: rgba(0, 113, 227, 0.08);
color: var(--bl-accent);
}
.playstyle-card p {
margin: 0;
color: var(--bl-text-secondary);
}
</style>

View File

@@ -0,0 +1,113 @@
<script setup>
import BaseBadge from '../base/BaseBadge.vue';
import BaseCard from '../base/BaseCard.vue';
defineProps({
town: {
type: Object,
required: true,
},
});
</script>
<template>
<BaseCard class="town-card" padding="sm" interactive>
<div class="town-card__cover" :style="{ backgroundImage: town.image ? `url(${town.image})` : '' }">
<div class="town-card__overlay"></div>
<div class="town-card__badges">
<span v-for="pill in town.quickBadges" :key="pill" class="town-card__icon-pill">{{ pill }}</span>
</div>
</div>
<div class="town-card__body">
<div class="town-card__top">
<h3>{{ town.title }}</h3>
<BaseBadge tone="accent">{{ town.scale }}</BaseBadge>
</div>
<p>{{ town.intro }}</p>
<div class="town-card__meta">
<span v-for="meta in town.meta" :key="meta">{{ meta }}</span>
</div>
</div>
</BaseCard>
</template>
<style scoped>
.town-card {
padding: 0;
overflow: hidden;
}
.town-card__cover {
position: relative;
min-height: 148px;
background:
linear-gradient(135deg, rgba(102, 126, 234, 0.88), rgba(118, 75, 162, 0.9)),
center/cover no-repeat;
}
.town-card__overlay {
position: absolute;
inset: 0;
background: linear-gradient(to top, rgba(15, 23, 42, 0.32), transparent 55%);
}
.town-card__badges {
position: absolute;
bottom: 14px;
left: 16px;
display: flex;
gap: 8px;
z-index: 1;
}
.town-card__icon-pill {
width: 28px;
height: 28px;
border-radius: 50%;
display: grid;
place-items: center;
background: rgba(255, 255, 255, 0.92);
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.14);
font-size: 0.72rem;
font-weight: 700;
}
.town-card__body {
padding: 24px 20px 20px;
display: grid;
gap: 12px;
}
.town-card__top {
display: flex;
justify-content: space-between;
gap: 12px;
}
.town-card__top h3,
.town-card__body p {
margin: 0;
}
.town-card__body p {
color: var(--bl-text-secondary);
}
.town-card__meta {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 6px;
padding-top: 14px;
border-top: 1px solid rgba(0, 0, 0, 0.05);
}
.town-card__meta span {
padding: 4px 10px;
border-radius: 999px;
background: #f5f5f7;
font-size: 0.75rem;
color: var(--bl-text-secondary);
}
</style>

256
src/demoData.js Normal file
View File

@@ -0,0 +1,256 @@
export const navItems = [
{ label: '文档', href: '/doc.html' },
{ label: '地图', href: '/map.html' },
{ label: '设施', href: '/facilities.html' },
{ label: '城镇', href: '/towns.html' },
{ label: '公告', href: '/announcements.html' },
{ label: '相册', href: '/photo.html' },
{ label: '数据', href: '/stats.html' },
{ label: '赞助', href: '/sponsor.html' },
];
export const facilityItems = [
{
id: 'gold-farm',
title: '猪灵金粒塔',
intro: '映射自 facilities 页面标准卡片,主打高吞吐产能与低学习成本。',
status: 'online',
statusLabel: '运行中',
type: '资源',
dimension: '下界',
location: '下界 420 / 128 / -180临近地狱交通干道。',
meta: ['下界', '高产出', '公共'],
contributors: ['LunaDeer', 'Aster', 'Moka'],
instructions: '站在指定 AFK 平台即可,战利品统一进入分拣区。',
notes: '请勿修改开关状态,离开前确认背包已清理。',
},
{
id: 'ice-highway',
title: '主世界冰船高速',
intro: '保留旧站设施卡片的低密度信息结构,但统一卡片圆角、阴影和标签。',
status: 'maintenance',
statusLabel: '维护中',
type: '基建',
dimension: '主世界',
location: '主世界 0 / 90 / 0 起点,贯穿主要城镇枢纽。',
meta: ['主世界', '导航', '联通'],
contributors: ['Kite', 'Reimu'],
instructions: '使用冰船沿路牌导航,支线入口保持单向通行。',
notes: '正在更换部分分叉节点,请留意现场提示。',
},
];
export const townItems = [
{
id: 'new-horizon',
title: '新曙光港',
intro: '大型海港型城镇,强调公共建筑与社区活动。',
scale: '大型',
recruitment: '欢迎加入',
image: 'https://images.unsplash.com/photo-1518005020951-eccb494ad742?auto=format&fit=crop&w=1200&q=80',
quickBadges: ['大', '建', '招'],
meta: ['海港', '工业', '社区活动'],
location: '主世界 1820 / 76 / -660地图港湾边缘。',
founders: ['LunaDeer', 'Hoshino'],
members: ['Kite', 'Moka', 'Sora', 'Yui'],
description: '城镇主打海港景观与工坊区联动,欢迎偏好合作建造与公共项目的玩家。',
},
{
id: 'pinefield',
title: '松野平原',
intro: '中型聚落,擅长农业与景观建造,氛围稳定安静。',
scale: '中型',
recruitment: '可以考虑',
image: 'https://images.unsplash.com/photo-1506744038136-46273834b3fb?auto=format&fit=crop&w=1200&q=80',
quickBadges: ['中', '农', '待'],
meta: ['田园', '建筑', '合作'],
location: '主世界 -940 / 72 / 1330松林与平原交界。',
founders: ['Aster'],
members: ['Mika', 'Ari', 'Shino'],
description: '以大面积农田、温室与木构住宅区为核心,适合慢节奏玩家。',
},
];
export const announcementItems = [
{
id: 'spring-festival',
category: 'activity',
categoryLabel: '活动',
time: '2026-03-18',
title: '春季建筑节开启报名',
intro: '时间线卡片保留旧站公告页的摘要 + 展开正文交互。',
content: [
{ title: '活动内容', body: '以“港口与交通”为主题,自由组队完成建筑作品。' },
{ title: '奖励说明', body: '前三名可获得纪念头颅、展示位与服务器鸣谢。' },
],
},
{
id: 'nether-maintenance',
category: 'maintenance',
categoryLabel: '维护',
time: '2026-03-12',
title: '下界交通网维护窗口',
intro: '将在周末凌晨进行区块清理与路标补全,期间部分支线不可用。',
content: [
{ title: '维护范围', body: '主下界站与三条分支高速。' },
{ title: '玩家影响', body: '维护期间建议改用主世界驿站传送。' },
],
},
];
export const leaderboardBoards = [
{
title: '总在线时长',
subtitle: '映射自 stats 页面排行卡模式',
icon: '⌛',
color: '#ffd700',
top: { name: 'LunaDeer', value: '1,248h' },
entries: [
{ rank: 2, name: 'Aster', value: '1,102h' },
{ rank: 3, name: 'Kite', value: '963h' },
{ rank: 4, name: 'Moka', value: '881h' },
],
},
{
title: '建筑方块放置',
subtitle: '保留彩色顶部强调,但统一结构',
icon: '▣',
color: '#9b59b6',
top: { name: 'Hoshino', value: '84.3k' },
entries: [
{ rank: 2, name: 'Shino', value: '73.1k' },
{ rank: 3, name: 'Mika', value: '68.9k' },
{ rank: 4, name: 'Ari', value: '61.2k' },
],
},
];
export const playerItems = [
{
id: 'luna',
name: 'LunaDeer',
avatar: 'https://minotar.net/helm/LunaDeer/160.png',
uuid: '00000000-0000-0000-0009-01f0198a2ae4',
highlights: [
{ label: '在线', value: '1248h' },
{ label: '放置', value: '84k' },
{ label: '死亡', value: '18' },
],
details: [
{ label: '行走距离', value: '1,204 km' },
{ label: '方块放置', value: '84,301' },
{ label: '方块挖掘', value: '72,044' },
{ label: '总死亡', value: '18' },
{ label: '击杀数', value: '2,381' },
{ label: '在线时长', value: '1,248 小时' },
],
},
{
id: 'aster',
name: 'Aster',
avatar: 'https://minotar.net/helm/Aster/160.png',
uuid: '00000000-0000-0000-0009-01f02c512c19',
highlights: [
{ label: '在线', value: '1102h' },
{ label: '放置', value: '73k' },
{ label: '死亡', value: '25' },
],
details: [
{ label: '行走距离', value: '986 km' },
{ label: '方块放置', value: '73,120' },
{ label: '方块挖掘', value: '58,004' },
{ label: '总死亡', value: '25' },
{ label: '击杀数', value: '1,487' },
{ label: '在线时长', value: '1,102 小时' },
],
},
];
export const donationItems = [
{
name: 'Moka',
message: '用于升级世界备份磁盘与日志归档空间。',
project: '存储扩容',
amount: '¥288',
time: '2026-03-15',
},
{
name: 'Kite',
message: '支持活动服务器与小游戏区维护。',
project: '活动专项',
amount: '¥168',
time: '2026-03-10',
},
];
export const bentoItems = [
{
title: '纯净原版',
description: '无破坏平衡的重型插件,尽量贴近单机原版体验。',
icon: '✦',
size: 'large',
background: 'linear-gradient(135deg, rgba(59,130,246,0.92), rgba(34,197,94,0.82))',
},
{
title: '深度自研',
description: '核心逻辑自主维护,页面与服务端体验可持续迭代。',
icon: '⌘',
size: 'medium',
background: 'linear-gradient(135deg, rgba(17,24,39,0.9), rgba(29,78,216,0.78))',
},
{
title: '原汁原味',
description: '生成、红石与生态参数保持克制调整。',
icon: '◇',
size: 'medium',
background: 'linear-gradient(135deg, rgba(245,158,11,0.92), rgba(249,115,22,0.82))',
},
{
title: '免费圈地',
description: '2048*2048 超大领地',
icon: '⌂',
size: 'small',
background: 'linear-gradient(135deg, rgba(14,165,233,0.88), rgba(37,99,235,0.78))',
},
{
title: '基岩互通',
description: '手机电脑随时畅玩',
icon: '◫',
size: 'small',
background: 'linear-gradient(135deg, rgba(244,63,94,0.88), rgba(190,24,93,0.78))',
},
{
title: '自有硬件',
description: '物理工作站,稳定运行',
icon: '▤',
size: 'small',
background: 'linear-gradient(135deg, rgba(71,85,105,0.88), rgba(15,23,42,0.82))',
},
{
title: '娱乐玩法',
description: '空岛、跑酷与活动副本并存',
icon: '◈',
size: 'small',
background: 'linear-gradient(135deg, rgba(16,185,129,0.88), rgba(5,150,105,0.82))',
},
];
export const joinDevices = [
{ id: 'pc', name: 'PC / Java', icon: '⌘', description: '适合完整原版与红石体验。' },
{ id: 'ios', name: 'iOS / 基岩', icon: '◫', description: '移动端快速加入与语音社交。' },
{ id: 'android', name: 'Android / 基岩', icon: '▣', description: '便携游玩,适合碎片化上线。' },
];
export const playstyles = [
{ id: 'town', name: '城镇共建', icon: '⌂', description: '适合喜欢合作建造、分工和社区运营的玩家。' },
{ id: 'industry', name: '工业设施', icon: '▤', description: '偏向农场、物流与高效率自动化。' },
{ id: 'friends', name: '朋友联机', icon: '◎', description: '以小团体长期生存和轻社交为主。' },
{ id: 'solo', name: '独狼探索', icon: '◇', description: '更注重远行、冒险与个人节奏。' },
];
export const sponsorSummary = {
title: '支持白鹿原服务器',
description: '延续 sponsor 页面的大额中心化弹窗结构,但把信息整理为更清晰的摘要和行动区。',
total: '¥42,680',
supporters: '187',
};

View File

@@ -1,28 +1,68 @@
:root { :root {
color-scheme: light; color-scheme: light;
font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; font-family: "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif;
line-height: 1.5; line-height: 1.5;
font-weight: 400; font-weight: 400;
color: #172033; color: #1d1d1f;
background: #f4f7fb; background: #f5f5f7;
font-synthesis: none; font-synthesis: none;
text-rendering: optimizeLegibility; text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
--bl-bg: #f5f5f7;
--bl-bg-soft: #fbfbfd;
--bl-surface: rgba(255, 255, 255, 0.92);
--bl-surface-strong: #ffffff;
--bl-surface-muted: #f0f2f5;
--bl-text: #1d1d1f;
--bl-text-secondary: #6e6e73;
--bl-text-tertiary: #8d8d92;
--bl-border: rgba(0, 0, 0, 0.08);
--bl-border-strong: rgba(0, 0, 0, 0.12);
--bl-accent: #0071e3;
--bl-accent-strong: #005ec0;
--bl-green: #34c759;
--bl-gold: #d4a53a;
--bl-warning: #f59e0b;
--bl-danger: #ef4444;
--bl-purple: #8b5cf6;
--bl-radius-xl: 30px;
--bl-radius-lg: 22px;
--bl-radius-md: 16px;
--bl-radius-sm: 12px;
--bl-shadow-soft: 0 4px 20px rgba(0, 0, 0, 0.05);
--bl-shadow-card: 0 12px 32px rgba(0, 0, 0, 0.08);
--bl-shadow-modal: 0 28px 80px rgba(17, 24, 39, 0.24);
--bl-transition: all 0.35s cubic-bezier(0.25, 1, 0.5, 1);
--bl-content-width: 1200px;
--bl-header-height: 44px;
} }
* { * {
box-sizing: border-box; box-sizing: border-box;
} }
html {
scroll-behavior: smooth;
}
body { body {
margin: 0; margin: 0;
min-width: 320px; min-width: 320px;
min-height: 100vh; min-height: 100vh;
background: background:
radial-gradient(circle at top left, rgba(81, 146, 255, 0.18), transparent 32%), radial-gradient(circle at top left, rgba(0, 113, 227, 0.12), transparent 28%),
radial-gradient(circle at bottom right, rgba(48, 196, 141, 0.16), transparent 28%), radial-gradient(circle at bottom right, rgba(52, 199, 89, 0.08), transparent 24%),
#f4f7fb; linear-gradient(180deg, #f7f7f9 0%, #f3f4f7 100%);
color: var(--bl-text);
}
body.bl-modal-open {
overflow: hidden;
}
a {
color: inherit;
} }
button, button,
@@ -32,83 +72,109 @@ select {
font: inherit; font: inherit;
} }
button {
border: 0;
}
img {
max-width: 100%;
display: block;
}
#app { #app {
min-height: 100vh; min-height: 100vh;
} }
.app-shell { .bl-shell {
width: min(1100px, calc(100% - 32px)); width: min(var(--bl-content-width), calc(100% - 32px));
margin: 0 auto; margin: 0 auto;
padding: 56px 0 72px;
} }
.hero-card, .bl-section-heading {
.status-grid article { display: flex;
border: 1px solid rgba(23, 32, 51, 0.08); justify-content: space-between;
border-radius: 24px; align-items: flex-end;
background: rgba(255, 255, 255, 0.88); gap: 16px;
box-shadow: 0 20px 60px rgba(40, 62, 98, 0.08); margin-bottom: 22px;
backdrop-filter: blur(16px);
} }
.hero-card { .bl-section-heading h2,
padding: 36px; .bl-section-heading h3,
.bl-section-heading p {
margin: 0;
} }
.eyebrow { .bl-section-title {
margin: 0 0 12px; font-size: clamp(1.35rem, 2vw, 1.8rem);
font-size: 0.85rem;
font-weight: 700; font-weight: 700;
letter-spacing: 0.16em; letter-spacing: -0.02em;
text-transform: uppercase;
color: #2d6cdf;
} }
.hero-card h1 { .bl-section-copy {
margin: 0; color: var(--bl-text-secondary);
font-size: clamp(2rem, 5vw, 3.4rem); max-width: 720px;
line-height: 1.05;
} }
.intro { .bl-grid {
max-width: 680px;
margin: 18px 0 0;
font-size: 1.05rem;
color: #49556b;
}
.status-grid {
display: grid; display: grid;
gap: 24px;
}
.bl-grid-2 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.bl-grid-3 {
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 20px;
margin-top: 24px;
} }
.status-grid article { .bl-grid-4 {
padding: 24px; grid-template-columns: repeat(4, minmax(0, 1fr));
} }
.status-grid h2 { .bl-muted {
margin: 0 0 10px; color: var(--bl-text-secondary);
font-size: 1.1rem;
} }
.status-grid p { .bl-pill {
margin: 0; display: inline-flex;
color: #5b6780; align-items: center;
gap: 6px;
min-height: 28px;
padding: 0 12px;
border-radius: 999px;
}
.bl-demo-chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 12px;
border-radius: 999px;
border: 1px solid var(--bl-border);
background: rgba(255, 255, 255, 0.68);
color: var(--bl-text-secondary);
}
@media (max-width: 1100px) {
.bl-grid-4 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
} }
@media (max-width: 840px) { @media (max-width: 840px) {
.app-shell { .bl-shell {
width: min(100% - 24px, 1100px); width: min(var(--bl-content-width), calc(100% - 24px));
padding-top: 28px;
} }
.hero-card { .bl-grid-2,
padding: 24px; .bl-grid-3,
} .bl-grid-4 {
.status-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.bl-section-heading {
align-items: flex-start;
flex-direction: column;
}
} }