feat: add TownsPage and router configuration

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

View File

@@ -1,127 +1,307 @@
---
description: "Use when rebuilding old-html-ver pages into Vue UI components, page layouts, composables, or styles for the bailuyuan website. Covers component extraction, custom UI design, and canonical legacy pattern selection."
name: "Vue UI Migration"
applyTo: "src/**/*.vue, src/**/*.js, src/**/*.css"
description: "Use when migrating old-html-ver pages into Vue for the bailuyuan website while preserving exact legacy layout, behavior, copy, deep-link behavior, and metadata. Covers removing the temporary review page and demo data, then rebuilding each legacy page 1:1 in Vue."
name: "Vue Legacy Page Parity Migration"
applyTo: "src/**/*.vue, src/**/*.js, src/**/*.css, vite.config.js, package.json, index.html"
---
# Bailuyuan Vue UI Migration Guidelines
# Bailuyuan Vue Legacy Page Parity Migration
## Goal
- Rebuild the legacy pages in `old-html-ver/` with Vue 3 components in `src/`.
- Do not use external UI component libraries.
- Recreate the site's visual language from the legacy HTML/CSS, but normalize duplicated styles into a smaller, cleaner component system.
- When similar legacy UIs use different implementations, keep the strongest pattern and discard weaker duplicates.
- Replace the temporary Vue component review shell with the real site.
- Migrate every page under `old-html-ver/` to Vue.
- Preserve the legacy site's layout, design, copy, interaction details, responsive behavior, and externally visible navigation behavior.
- Keep the data contracts in `public/data/` and `public/stats/` intact unless a task explicitly says otherwise.
## Source Priority
## Non-Negotiable Rules
- Treat `old-html-ver/js/components.js` as the source of truth for shared layout primitives: navbar, mobile menu, page hero, footer.
- Treat `old-html-ver/css/style.css` as the source of truth for global tokens and shared layout behavior.
- Treat `old-html-ver/announcements.html`, `old-html-ver/facilities.html`, and `old-html-ver/towns.html` plus their page CSS as the canonical source for search, filter, card, badge, and detail interaction patterns.
- Treat `old-html-ver/index.html` as the source for the home-page-only bento grid and hero interaction patterns.
- Treat `old-html-ver/stats.html`, `old-html-ver/sponsor.html`, and `old-html-ver/join.html` as sources for page-specific specialized components, not as the default style baseline for shared controls.
- Migrate with exact page parity. Do not redesign, modernize, normalize, simplify, or reinterpret the old site on your own.
- Do not keep the site as a temporary single-page component review shell. A real SPA is allowed, but it must preserve user-visible page behavior and must not remove deep-linking and direct access semantics from the legacy site.
- Do not keep `src/App.vue` as a component gallery or leave `src/demoData.js` in the real page flow.
- Do not replace production-like data with mock cards, placeholder lists, or hand-written demo objects once a page is being migrated.
- Do not silently change legacy copy, labels, icon meaning, filter order, badge meaning, modal sections, CTA wording, or page information architecture.
- Do not drop SEO metadata, structured data, verification tags, canonical URLs, or iframe targets from legacy pages unless explicitly requested.
- Do not introduce external UI kits such as Element Plus, Vuetify, Naive UI, or Ant Design Vue.
## Canonical Pattern Choices
## Current Vue Baseline
- Use the announcements/facilities/towns control bar as the standard filter UI:
- card-like `controls-section`
- `search-box` with leading icon
- labeled `filter-group`
- pill `filter-tag` buttons with a strong active state
- Prefer the facilities/towns card and modal structure as the base pattern for data-detail pages.
- Keep the announcement timeline as a dedicated page pattern instead of forcing it into the facilities/towns card layout.
- Keep leaderboard cards, donation cards, and join wizard cards as specialized components that inherit shared spacing, radius, shadow, and button rules from base UI primitives.
- If a sponsor or stats page control differs from the announcements/facilities/towns version, default to the latter unless the page has a real functional need for a custom variant.
- `src/App.vue` is currently a temporary review page for shared components. It is not a valid final page and must be removed or replaced as part of real migration work.
- `src/demoData.js` is temporary inspection data. It is useful only for base component review and must not remain as the source for migrated pages.
- `src/components/` already contains reusable layout, base, shared, and detail components. Reuse them only when they can reproduce the legacy page without changing the output.
- `vite.config.js` currently has only the default single-entry setup. Future migration work may keep a single-entry SPA or use multiple entries, but the final app must reproduce legacy navigation and direct-entry behavior instead of one showcase page.
## Components To Extract First
## Source Of Truth
- Layout primitives:
- `SiteNavbar`
- `MobileNavDrawer`
- `PageHero`
- `SiteFooter`
- Base UI primitives:
- `BaseButton`
- `BaseCard`
- `BaseModal`
- `BaseBadge`
- `SearchBox`
- `FilterTagGroup`
- `EmptyState`
- `LoadMoreButton`
- Shared content components:
- `FilterPanel`
- `FacilityCard`
- `TownCard`
- `AnnouncementTimeline`
- `AnnouncementCard`
- `LeaderboardCard`
- `PlayerCard`
- `DonationCard`
- `FeatureBentoGrid`
- `FeatureBentoCard`
- `JoinWizard`
- `DeviceCard`
- `PlaystyleCard`
- Detail components:
- `FacilityDetailModal`
- `TownDetailModal`
- `PlayerDetailModal`
- `SponsorModal`
- `ModalSection`
- Shared layout and global visual system:
- `old-html-ver/js/components.js`
- `old-html-ver/css/style.css`
- Page-specific HTML structure and page head metadata:
- `old-html-ver/*.html`
- Page-specific interactions:
- `old-html-ver/js/*.js`
- Page-specific styles:
- `old-html-ver/css/pages/*.css`
- Runtime data contracts:
- `public/data/*.json`
- `public/data/*.txt`
- `public/stats/summary.json`
- `public/stats/*.json`
## Expected Component Responsibilities
## Shared Legacy Primitives To Preserve
- Shared components must be prop-driven and reusable across multiple pages.
- Page components should compose shared components instead of duplicating old HTML blocks.
- Data-driven sections should accept structured props for badges, coordinates, contributor lists, rich text blocks, media blocks, and status labels.
- Repeated filter and search logic should move into Vue composables instead of being reimplemented in each page.
- Navbar, mobile menu, and footer come from `old-html-ver/js/components.js`.
- The reusable page hero pattern comes from `old-html-ver/js/components.js` plus `old-html-ver/css/style.css`.
- The translucent fixed top navigation, 44px header offset, rounded cards, soft shadows, and hero overlays come from `old-html-ver/css/style.css`.
- Shared sponsor parsing logic comes from `old-html-ver/js/data_utils.js` and the `data/sponsors.txt` format.
## URL And Architecture Rules
- A SPA architecture is allowed.
- Multiple page entries are also allowed.
- Choose the architecture that best preserves exact legacy behavior under static hosting.
- Direct access must still work for every migrated legacy page state that users can reasonably share or bookmark.
- For announcements, facilities, and towns, deep links must open the correct page and automatically expand or open the corresponding detail item or modal.
- It is acceptable to replace legacy `.html` paths with SPA routes only if external navigation remains stable enough for users and existing shared links continue to work or are intentionally redirected.
- If `vite.config.js` changes, keep deployment and static asset loading compatible with GitHub Pages style hosting.
- Keep relative fetch paths and static hosting compatibility.
## Reuse Policy For Existing Vue Components
- `SiteNavbar`, `MobileNavDrawer`, `PageHero`, and `SiteFooter` should be aligned to the legacy navbar, mobile menu, hero, and footer behavior from `old-html-ver/js/components.js`.
- `SearchBox`, `FilterTagGroup`, `FilterPanel`, `BaseModal`, `LoadMoreButton`, and related shared components are allowed starting points, but only if the migrated page still matches the legacy page exactly.
- `FeatureBentoGrid`, `JoinWizard`, `FacilityCard`, `TownCard`, `AnnouncementTimeline`, `LeaderboardCard`, `PlayerCard`, `DonationCard`, and detail modals are starting points, not final truth. Adjust them to the old page instead of adjusting the old page to the component.
- If a legacy page needs markup or behavior that existing components cannot express exactly, change the shared component or add a page-specific wrapper instead of forcing a mismatch.
## Page Inventory And Migration Notes
### Home Page
- Source files:
- `old-html-ver/index.html`
- `old-html-ver/js/script.js`
- `old-html-ver/js/data_utils.js`
- `old-html-ver/css/style.css`
- Must preserve:
- skip link to main content
- full head metadata and structured data
- hero background, overlay, and title layout
- rotating subtitle words with the same cadence
- runtime timer from `2021-09-14T09:57:59`
- copy-to-clipboard server IP box and tooltip behavior
- live server status from `https://api.mcstatus.io/v2/status/java/mcpure.lunadeer.cn`
- online player tooltip list and offline fallback states
- bento feature grid with the same card count and hierarchy
- top sponsor section built from `data/sponsors.txt`
- crowdfunding section built from `data/fund_progress.txt`
- Migration notes:
- do not replace live status or counters with static placeholders
- keep the hidden crowdfunding section behavior that only shows when valid data exists
### Announcements Page
- Source files:
- `old-html-ver/announcements.html`
- `old-html-ver/js/announcements_script.js`
- `old-html-ver/css/pages/announcements.css`
- Data source:
- `public/data/announcements.json`
- Must preserve:
- page hero content and layout
- search input behavior
- category filter buttons and active states
- timeline layout and category-specific styling
- first card expanded by default
- click-to-expand with only one expanded card at a time
- direct link behavior that opens the matching item automatically
- share button that copies the deep link
- hidden edit mode triggered by typing `edit`
- rich content block rendering for text, image, and Bilibili video items
- Migration notes:
- keep the secret keyboard shortcut and console hint unless explicitly removed
- keep stable deep-link id generation semantics, whether implemented with hash or router state
### Facilities Page
- Source files:
- `old-html-ver/facilities.html`
- `old-html-ver/js/facilities_script.js`
- `old-html-ver/css/pages/facilities.css`
- Data source:
- `public/data/facilities.json`
- Must preserve:
- search input
- independent type and dimension filters
- facility card layout and status indicator styles
- detail modal content sections
- map link format to `https://mcmap.lunadeer.cn/`
- contributor avatar tags from Minotar
- Bilibili video block rendering in instructions and notes
- direct link behavior that auto-opens the correct facility modal
- Migration notes:
- do not flatten the modal content model into plain strings
- keep status meaning for `online`, `maintenance`, and `offline`
### Towns Page
- Source files:
- `old-html-ver/towns.html`
- `old-html-ver/js/towns_script.js`
- `old-html-ver/css/pages/towns.css`
- Data source:
- `public/data/towns.json`
- Must preserve:
- search input
- scale, type, and recruitment filters
- town card layout with logo image or gradient fallback
- icon badge meanings for scale, type, and recruitment
- detail modal structure
- founders and members sections with avatars
- coordinates secrecy behavior when `coordinatesSecret === true`
- direct link behavior that auto-opens the correct town modal
- Migration notes:
- keep gradient fallback behavior when no logo exists
- do not expose coordinates if the legacy data marks them as secret
### Stats Page
- Source files:
- `old-html-ver/stats.html`
- `old-html-ver/js/stats_script.js`
- `old-html-ver/css/pages/stats.css`
- Data sources:
- `public/stats/summary.json`
- `public/stats/*.json`
- generated by `scripts/statsprocess.py`
- Must preserve:
- updated-at text display
- six leaderboard blocks and their sort rules
- searchable player grid
- incremental load-more pagination with page size 24
- player modal summary fields
- lazy loading of per-player detail JSON when a modal opens
- accordion structure for categorized detailed stats
- search inside large accordion sections
- Migration notes:
- do not swap in `src/demoData.js` player samples
- do not hand-edit generated stats JSON files
- keep raw sort semantics for `walk_raw` and `play_time_raw`
### Sponsor Page
- Source files:
- `old-html-ver/sponsor.html`
- `old-html-ver/js/sponsor_script.js`
- `old-html-ver/css/pages/sponsor.css`
- `old-html-ver/js/data_utils.js`
- Data sources:
- `public/data/sponsors.txt`
- `public/data/fund_progress.txt` when referenced by the page
- Must preserve:
- animated cumulative total amount display
- search input
- project filter generation from real sponsor data
- sponsor grid card layout and order
- sponsor modal with separate desktop QR and mobile button views
- mobile detection behavior
- empty state and load failure fallback text
- Migration notes:
- continue parsing `data/sponsors.txt` as `name, project, amount, [date]`
- keep newest-first display order by reversing the parsed list
### Join Page
- Source files:
- `old-html-ver/join.html`
- `old-html-ver/js/join_script.js`
- `old-html-ver/css/pages/join.css`
- `old-html-ver/js/marked.min.js`
- Data source:
- `public/data/convention.md`
- Must preserve:
- four-step wizard structure
- progress indicator states
- markdown loading for the convention step
- checkbox gating for agreement
- device selection cards
- Java and Bedrock edition toggle behavior
- launcher recommendation blocks per device and edition
- previous and next button states
- final step button set and tutorial rendering flow
- Migration notes:
- keep the lazy generation of step-3 tutorial content tied to the selected device and edition
- using a markdown parser is acceptable because the legacy page already relies on one
### Doc, Map, And Photo Pages
- Source files:
- `old-html-ver/doc.html`
- `old-html-ver/map.html`
- `old-html-ver/photo.html`
- Must preserve:
- navbar only plus fullscreen iframe layout
- inline page sizing behavior with the 44px navbar offset
- external iframe targets exactly as in the legacy site
- page-specific head metadata and structured data
- Migration notes:
- do not over-engineer these pages
- do not wrap them in extra containers that change the viewport sizing behavior
## Demo Removal Rules
- Remove the temporary showcase narrative from `src/App.vue` before treating migration as complete.
- Remove imports from `src/demoData.js` from any real page entry.
- Do not leave placeholder hero copy such as component review or UI audit text in production-facing pages.
- If demo data is still needed for isolated component development, keep it out of real page entry files and public routes.
## Styling Rules
- Keep the site's existing visual direction: soft cards, rounded corners, translucent navigation, strong hero imagery, restrained shadows, and Chinese content-first spacing.
- Reuse the legacy CSS variable vocabulary where it still makes sense, but consolidate it in the Vue codebase instead of copying page CSS wholesale.
- Do not copy-paste duplicated legacy class trees unless they are the chosen canonical pattern.
- Normalize spacing, radii, shadows, and interactive states across components.
- Preserve responsive behavior from the legacy site, especially navbar/mobile menu, hero scaling, filter wrapping, grid collapse, and modal usability.
- When creating styles, prefer local component styles or clearly organized shared styles over page-specific one-off overrides.
- Preserve the legacy CSS variable language and visual rhythm from `old-html-ver/css/style.css`.
- Match legacy spacing, card density, section order, control grouping, icon usage, and breakpoint behavior.
- Preserve hover states, animation cadence, focus states, and modal open-close feel where they are visible to users.
- Avoid large global style rewrites before parity is reached.
## Architecture Rules For Vue Work
## Interaction And Data Rules
- Build shared primitives before page-specific wrappers.
- Use slots only when content structure truly varies; otherwise use typed props with clear names.
- Keep interaction state inside the component or a focused composable such as search, filtering, modal visibility, and pagination.
- Avoid direct DOM manipulation when Vue state and template bindings can express the behavior.
- Preserve current data contracts from `public/data/` and `public/stats/` unless the task explicitly includes changing them.
- Prefer Vue state and template bindings over direct DOM mutation, but keep the visible behavior identical.
- Keep fetch paths relative for local files.
- Maintain current external integrations:
- `https://api.mcstatus.io/v2/status/java/mcpure.lunadeer.cn`
- `https://minotar.net/...`
- `https://crafatar.com/...`
- Bilibili embed iframes
- `https://schema.lunadeer.cn/...`
- `https://mcmap.lunadeer.cn/`
- `https://mcphoto.lunadeer.cn/`
- Render fallback text instead of crashing when remote requests fail, matching the legacy behavior.
## Legacy To Vue Mapping
## Recommended Migration Order
- `components.js` navbar/footer/hero -> shared layout components.
- Announcements page -> `FilterPanel` + `AnnouncementTimeline` + expandable `AnnouncementCard`.
- Facilities page -> `FilterPanel` + `FacilityCard` grid + `FacilityDetailModal`.
- Towns page -> `FilterPanel` + `TownCard` grid + `TownDetailModal`.
- Stats page -> `LeaderboardCard` grid + `PlayerCard` grid + `PlayerDetailModal` + pagination controls.
- Sponsor page -> `DonationCard` grid + `SponsorModal`, while still reusing shared search and filter primitives.
- Join page -> `JoinWizard`, `ProgressStep`, `DeviceCard`, `EditionToggle`, `PlaystyleCard`.
- Home page -> `PageHero` specialization + `FeatureBentoGrid` + sponsor highlight section.
1. Replace the review shell architecture so the project can serve legacy page URLs.
2. Wire shared layout pieces to match `old-html-ver/js/components.js` and `old-html-ver/css/style.css` precisely.
3. Migrate the simple iframe pages: `doc.html`, `map.html`, `photo.html`.
4. Migrate the filter-and-modal data pages: announcements, facilities, towns.
5. Migrate sponsor and stats using the real public data files.
6. Migrate the join wizard with markdown rendering.
7. Migrate the home page with live status, timers, sponsor totals, and crowdfunding.
8. Remove any remaining demo-only entry points and data.
## What To Avoid
## Verification Checklist
- Do not introduce Element Plus, Vuetify, Naive UI, Ant Design Vue, or similar UI kits.
- Do not keep one Vue component per old HTML page section if the section is really a shared pattern.
- Do not preserve inconsistent legacy styling just because it already exists.
- Do not port legacy imperative JavaScript event wiring directly into Vue components.
- Do not silently invent a new visual language that ignores the old site structure and tone.
- Compare new and old pages side by side on desktop and mobile.
- Check that page order, section order, and copy match.
- Check that filters, searches, empty states, and modal behavior match.
- Check deep links for announcements, facilities, and towns, including direct access that auto-opens the correct item or modal.
- Check the home page timer, subtitle rotation, copy-to-clipboard behavior, and live status fallbacks.
- Check stats leaderboard sorting, player search, pagination, and lazy-loaded details.
- Check sponsor total animation, project filters, and desktop vs mobile donation modal content.
- Check join wizard gating, device selection, edition toggle, and markdown rendering.
- Check iframe pages fill the viewport correctly below the fixed navbar.
- Check no production route depends on `src/demoData.js`.
- Check head metadata, structured data, and canonical URLs are preserved for each migrated page.
## Default Implementation Bias
## When Generating New Vue Code
- If multiple legacy versions exist, choose the version that is clearer, more reusable, and visually more stable.
- For search and filtering, bias toward the announcements/facilities/towns implementation.
- For detail dialogs, bias toward the facilities/towns modal structure.
- For cards on data listing pages, bias toward the facilities/towns content density instead of the lighter sponsor/stats cards unless the page truly needs the lighter form.
## When Generating New Vue UI
- State which legacy page and which legacy pattern you are mapping from.
- List which shared component should be reused before creating a new one.
- If creating a new component, explain why an existing shared primitive is insufficient.
- Keep markup semantic and accessible: buttons for actions, labels for fields, dialog semantics for modals, keyboard-friendly navigation states.
- State the exact legacy page being migrated.
- List the specific legacy files used as the source of truth.
- Explain which existing Vue components are being reused and why they still allow exact parity.
- If adding a new component or composable, explain what legacy behavior requires it.
- Treat parity regressions as bugs, even if the Vue implementation looks cleaner internally.

View File

@@ -3,11 +3,102 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="白鹿原 Minecraft 服务器官网 Vue 重构工程。" />
<title>白鹿原 Minecraft 服务器</title>
<title>白鹿原 Minecraft 服务器 - 永不换档的纯净原版生存Minecraft服务器</title>
<meta name="description" content="白鹿原是一个永不换档的纯净原版生存Minecraft我的世界服务器支持Java版与基岩版互通。提供免费圈地保护、自研管理插件紧跟最新游戏版本更新。物理工作站保障7×24小时稳定运行实时查看服务器在线状态与众筹进展。立即加入白鹿原开启纯净原版生存冒险之旅服务器地址mcpure.lunadeer.cn" />
<meta name="keywords" content="白鹿原Minecraft,白鹿原我的世界,白鹿原mc,Minecraft服务器,我的世界,我的世界服务器,纯净服务器,原版服务器,纯净生存,基岩互通,白鹿原,MC服务器,永不换档,免费圈地,Minecraft中国" />
<meta name="author" content="白鹿原 Minecraft 服务器" />
<meta name="robots" content="index, follow" />
<link rel="canonical" href="https://mcpure.lunadeer.cn/" />
<!-- Google Site Verification -->
<meta name="google-site-verification" content="ZMGHsJuJU3soEw09Xa0lfKTxhxEBKN-h-goxg5lhCRw" />
<!-- Bing Site Verification -->
<meta name="msvalidate.01" content="A46E723A4AEF6D9EEB1D9AB9DC1267FD" />
<!-- Open Graph -->
<meta property="og:type" content="website" />
<meta property="og:url" content="https://mcpure.lunadeer.cn/" />
<meta property="og:title" content="白鹿原 Minecraft 服务器 - 永不换档的纯净原版生存Minecraft服务器" />
<meta property="og:description" content="白鹿原——永不换档的纯净原版生存Minecraft服务器支持Java版与基岩版互通。免费圈地保护、自研管理插件、紧跟最新版本物理工作站保障全天候稳定运行。立即加入纯净生存冒险" />
<meta property="og:image" content="https://img.lunadeer.cn/i/2024/04/22/6625ce6c8ddc1.png" />
<meta property="og:site_name" content="白鹿原 Minecraft 服务器" />
<meta property="og:locale" content="zh_CN" />
<!-- Twitter -->
<meta property="twitter:card" content="summary_large_image" />
<meta property="twitter:url" content="https://mcpure.lunadeer.cn/" />
<meta property="twitter:title" content="白鹿原 Minecraft 服务器 - 永不换档的纯净原版生存Minecraft服务器" />
<meta property="twitter:description" content="白鹿原——永不换档的纯净原版生存Minecraft服务器支持Java版与基岩版互通。免费圈地保护、自研管理插件、紧跟最新版本物理工作站保障全天候稳定运行。立即加入纯净生存冒险" />
<meta property="twitter:image" content="https://img.lunadeer.cn/i/2024/04/22/6625ce6c8ddc1.png" />
<link rel="icon" href="/favicon.ico" type="image/x-icon" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="preconnect" href="https://img.lunadeer.cn" />
<link rel="dns-prefetch" href="https://outline.lunadeer.cn" />
<link rel="dns-prefetch" href="https://mcmap.lunadeer.cn" />
<link rel="dns-prefetch" href="https://mcphoto.lunadeer.cn" />
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;500;700&family=Inter:wght@400;600;800&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" />
<!-- Structured Data -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "GameServer",
"name": "白鹿原 Minecraft 服务器",
"description": "永不换档的纯净原版生存Minecraft服务器支持Java版和基岩版互通",
"url": "https://mcpure.lunadeer.cn/",
"logo": "https://img.lunadeer.cn/i/2024/04/22/6625ce6c8ddc1.png",
"image": "https://img.lunadeer.cn/i/2025/11/26/69267755e14e3.png",
"game": {
"@type": "VideoGame",
"name": "Minecraft",
"gamePlatform": ["Java Edition", "Bedrock Edition"]
},
"offers": {
"@type": "Offer",
"price": "0",
"priceCurrency": "CNY",
"availability": "https://schema.org/InStock"
},
"aggregateRating": {
"@type": "AggregateRating",
"ratingValue": "5",
"ratingCount": "100"
}
}
</script>
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "WebSite",
"name": "白鹿原 Minecraft 服务器",
"url": "https://mcpure.lunadeer.cn/",
"potentialAction": {
"@type": "SearchAction",
"target": "https://mcpure.lunadeer.cn/stats?q={search_term_string}",
"query-input": "required name=search_term_string"
}
}
</script>
</head>
<body>
<!-- GitHub Pages SPA redirect handler -->
<script>
(function(l) {
if (l.search[1] === '/') {
var decoded = l.search.slice(1).split('&').map(function(s) {
return s.replace(/~and~/g, '&')
}).join('?');
window.history.replaceState(null, null,
l.pathname.slice(0, -1) + decoded + l.hash
);
}
}(window.location))
</script>
<a href="#main-content" class="skip-to-main" style="position:absolute;left:-9999px;top:auto;width:1px;height:1px;overflow:hidden;">跳转到主内容</a>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
</html>

37
package-lock.json generated
View File

@@ -8,7 +8,9 @@
"name": "bailuyuan-web",
"version": "0.1.0",
"dependencies": {
"vue": "^3.5.13"
"marked": "^17.0.4",
"vue": "^3.5.13",
"vue-router": "^4.6.4"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.1",
@@ -879,6 +881,12 @@
"@vue/shared": "3.5.30"
}
},
"node_modules/@vue/devtools-api": {
"version": "6.6.4",
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz",
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==",
"license": "MIT"
},
"node_modules/@vue/reactivity": {
"version": "3.5.30",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.30.tgz",
@@ -1016,6 +1024,18 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/marked": {
"version": "17.0.4",
"resolved": "https://registry.npmjs.org/marked/-/marked-17.0.4.tgz",
"integrity": "sha512-NOmVMM+KAokHMvjWmC5N/ZOvgmSWuqJB8FoYI019j4ogb/PeRMKoKIjReZ2w3376kkA8dSJIP8uD993Kxc0iRQ==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
@@ -1202,6 +1222,21 @@
"optional": true
}
}
},
"node_modules/vue-router": {
"version": "4.6.4",
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz",
"integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==",
"license": "MIT",
"dependencies": {
"@vue/devtools-api": "^6.6.4"
},
"funding": {
"url": "https://github.com/sponsors/posva"
},
"peerDependencies": {
"vue": "^3.5.0"
}
}
}
}

View File

@@ -10,7 +10,9 @@
"update:stats": "python scripts/statsprocess.py"
},
"dependencies": {
"vue": "^3.5.13"
"marked": "^17.0.4",
"vue": "^3.5.13",
"vue-router": "^4.6.4"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.1",

20
public/404.html Normal file
View File

@@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>白鹿原 Minecraft 服务器</title>
<script>
// GitHub Pages SPA redirect: preserve path for vue-router
var pathSegmentsToKeep = 0;
var l = window.location;
l.replace(
l.protocol + '//' + l.hostname + (l.port ? ':' + l.port : '') +
l.pathname.split('/').slice(0, 1 + pathSegmentsToKeep).join('/') + '/?/' +
l.pathname.slice(1).split('/').slice(pathSegmentsToKeep).join('/').replace(/&/g, '~and~') +
(l.search ? '&' + l.search.slice(1).replace(/&/g, '~and~') : '') +
l.hash
);
</script>
</head>
<body></body>
</html>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

70
src/router.js Normal file
View File

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