mirror of
https://github.com/Coldsmiles/infstarweb.git
synced 2026-04-23 02:30:41 +08:00
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:
346
src/App.vue
346
src/App.vue
@@ -1,26 +1,322 @@
|
||||
<template>
|
||||
<main class="app-shell">
|
||||
<section class="hero-card">
|
||||
<p class="eyebrow">Vue Migration Workspace</p>
|
||||
<h1>白鹿原官网 Vue 重构基座已就绪</h1>
|
||||
<p class="intro">
|
||||
这里是新的 Vue 入口。旧版静态站仍保留在 old-html-ver 目录中,已迁入的新工作流会从 public 目录提供数据与静态资源。
|
||||
</p>
|
||||
</section>
|
||||
<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';
|
||||
|
||||
<section class="status-grid">
|
||||
<article>
|
||||
<h2>public 资源</h2>
|
||||
<p>旧站数据文件、统计 JSON 与 SEO 静态文件会从这里进入 Vite 构建产物。</p>
|
||||
</article>
|
||||
<article>
|
||||
<h2>scripts 任务</h2>
|
||||
<p>玩家统计脚本已迁到根目录工作流,可在构建前更新 public/stats。</p>
|
||||
</article>
|
||||
<article>
|
||||
<h2>下一步</h2>
|
||||
<p>后续可以在 src 下逐页重建组件、路由和布局,而不需要再改项目基础设施。</p>
|
||||
</article>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
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>
|
||||
<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">卡片结构按页面职责分化,但共用统一的 spacing、radius、shadow 和 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>
|
||||
71
src/components/base/BaseBadge.vue
Normal file
71
src/components/base/BaseBadge.vue
Normal 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>
|
||||
119
src/components/base/BaseButton.vue
Normal file
119
src/components/base/BaseButton.vue
Normal 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>
|
||||
62
src/components/base/BaseCard.vue
Normal file
62
src/components/base/BaseCard.vue
Normal 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>
|
||||
163
src/components/base/BaseModal.vue
Normal file
163
src/components/base/BaseModal.vue
Normal 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>
|
||||
59
src/components/base/EmptyState.vue
Normal file
59
src/components/base/EmptyState.vue
Normal 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>
|
||||
88
src/components/base/FilterTagGroup.vue
Normal file
88
src/components/base/FilterTagGroup.vue
Normal 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>
|
||||
26
src/components/base/LoadMoreButton.vue
Normal file
26
src/components/base/LoadMoreButton.vue
Normal 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>
|
||||
75
src/components/base/SearchBox.vue
Normal file
75
src/components/base/SearchBox.vue
Normal 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>
|
||||
101
src/components/detail/FacilityDetailModal.vue
Normal file
101
src/components/detail/FacilityDetailModal.vue
Normal 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>
|
||||
47
src/components/detail/ModalSection.vue
Normal file
47
src/components/detail/ModalSection.vue
Normal 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>
|
||||
79
src/components/detail/PlayerDetailModal.vue
Normal file
79
src/components/detail/PlayerDetailModal.vue
Normal 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>
|
||||
118
src/components/detail/SponsorModal.vue
Normal file
118
src/components/detail/SponsorModal.vue
Normal 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>
|
||||
111
src/components/detail/TownDetailModal.vue
Normal file
111
src/components/detail/TownDetailModal.vue
Normal 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
33
src/components/index.js
Normal 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';
|
||||
144
src/components/layout/MobileNavDrawer.vue
Normal file
144
src/components/layout/MobileNavDrawer.vue
Normal 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>
|
||||
112
src/components/layout/PageHero.vue
Normal file
112
src/components/layout/PageHero.vue
Normal 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>
|
||||
86
src/components/layout/SiteFooter.vue
Normal file
86
src/components/layout/SiteFooter.vue
Normal 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>
|
||||
189
src/components/layout/SiteNavbar.vue
Normal file
189
src/components/layout/SiteNavbar.vue
Normal 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>
|
||||
129
src/components/shared/AnnouncementCard.vue
Normal file
129
src/components/shared/AnnouncementCard.vue
Normal 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>
|
||||
53
src/components/shared/AnnouncementTimeline.vue
Normal file
53
src/components/shared/AnnouncementTimeline.vue
Normal 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>
|
||||
66
src/components/shared/DeviceCard.vue
Normal file
66
src/components/shared/DeviceCard.vue
Normal 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>
|
||||
71
src/components/shared/DonationCard.vue
Normal file
71
src/components/shared/DonationCard.vue
Normal 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>
|
||||
79
src/components/shared/FacilityCard.vue
Normal file
79
src/components/shared/FacilityCard.vue
Normal 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>
|
||||
71
src/components/shared/FeatureBentoCard.vue
Normal file
71
src/components/shared/FeatureBentoCard.vue
Normal 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>
|
||||
51
src/components/shared/FeatureBentoGrid.vue
Normal file
51
src/components/shared/FeatureBentoGrid.vue
Normal 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>
|
||||
108
src/components/shared/FilterPanel.vue
Normal file
108
src/components/shared/FilterPanel.vue
Normal 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>
|
||||
276
src/components/shared/JoinWizard.vue
Normal file
276
src/components/shared/JoinWizard.vue
Normal 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>
|
||||
125
src/components/shared/LeaderboardCard.vue
Normal file
125
src/components/shared/LeaderboardCard.vue
Normal 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>
|
||||
69
src/components/shared/PlayerCard.vue
Normal file
69
src/components/shared/PlayerCard.vue
Normal 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>
|
||||
64
src/components/shared/PlaystyleCard.vue
Normal file
64
src/components/shared/PlaystyleCard.vue
Normal 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>
|
||||
113
src/components/shared/TownCard.vue
Normal file
113
src/components/shared/TownCard.vue
Normal 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
256
src/demoData.js
Normal 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',
|
||||
};
|
||||
174
src/styles.css
174
src/styles.css
@@ -1,28 +1,68 @@
|
||||
:root {
|
||||
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;
|
||||
font-weight: 400;
|
||||
color: #172033;
|
||||
background: #f4f7fb;
|
||||
color: #1d1d1f;
|
||||
background: #f5f5f7;
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-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;
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(81, 146, 255, 0.18), transparent 32%),
|
||||
radial-gradient(circle at bottom right, rgba(48, 196, 141, 0.16), transparent 28%),
|
||||
#f4f7fb;
|
||||
radial-gradient(circle at top left, rgba(0, 113, 227, 0.12), transparent 28%),
|
||||
radial-gradient(circle at bottom right, rgba(52, 199, 89, 0.08), transparent 24%),
|
||||
linear-gradient(180deg, #f7f7f9 0%, #f3f4f7 100%);
|
||||
color: var(--bl-text);
|
||||
}
|
||||
|
||||
body.bl-modal-open {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
button,
|
||||
@@ -32,83 +72,109 @@ select {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
#app {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
width: min(1100px, calc(100% - 32px));
|
||||
.bl-shell {
|
||||
width: min(var(--bl-content-width), calc(100% - 32px));
|
||||
margin: 0 auto;
|
||||
padding: 56px 0 72px;
|
||||
}
|
||||
|
||||
.hero-card,
|
||||
.status-grid article {
|
||||
border: 1px solid rgba(23, 32, 51, 0.08);
|
||||
border-radius: 24px;
|
||||
background: rgba(255, 255, 255, 0.88);
|
||||
box-shadow: 0 20px 60px rgba(40, 62, 98, 0.08);
|
||||
backdrop-filter: blur(16px);
|
||||
.bl-section-heading {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
gap: 16px;
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
|
||||
.hero-card {
|
||||
padding: 36px;
|
||||
.bl-section-heading h2,
|
||||
.bl-section-heading h3,
|
||||
.bl-section-heading p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 12px;
|
||||
font-size: 0.85rem;
|
||||
.bl-section-title {
|
||||
font-size: clamp(1.35rem, 2vw, 1.8rem);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
color: #2d6cdf;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.hero-card h1 {
|
||||
margin: 0;
|
||||
font-size: clamp(2rem, 5vw, 3.4rem);
|
||||
line-height: 1.05;
|
||||
.bl-section-copy {
|
||||
color: var(--bl-text-secondary);
|
||||
max-width: 720px;
|
||||
}
|
||||
|
||||
.intro {
|
||||
max-width: 680px;
|
||||
margin: 18px 0 0;
|
||||
font-size: 1.05rem;
|
||||
color: #49556b;
|
||||
}
|
||||
|
||||
.status-grid {
|
||||
.bl-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));
|
||||
gap: 20px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.status-grid article {
|
||||
padding: 24px;
|
||||
.bl-grid-4 {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.status-grid h2 {
|
||||
margin: 0 0 10px;
|
||||
font-size: 1.1rem;
|
||||
.bl-muted {
|
||||
color: var(--bl-text-secondary);
|
||||
}
|
||||
|
||||
.status-grid p {
|
||||
margin: 0;
|
||||
color: #5b6780;
|
||||
.bl-pill {
|
||||
display: inline-flex;
|
||||
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) {
|
||||
.app-shell {
|
||||
width: min(100% - 24px, 1100px);
|
||||
padding-top: 28px;
|
||||
.bl-shell {
|
||||
width: min(var(--bl-content-width), calc(100% - 24px));
|
||||
}
|
||||
|
||||
.hero-card {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.status-grid {
|
||||
.bl-grid-2,
|
||||
.bl-grid-3,
|
||||
.bl-grid-4 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.bl-section-heading {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user