mirror of
https://github.com/Coldsmiles/infstarweb.git
synced 2026-04-22 18:20:43 +08:00
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:
@@ -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.
|
||||
97
index.html
97
index.html
@@ -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
37
package-lock.json
generated
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,10 +10,12 @@
|
||||
"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",
|
||||
"vite": "^5.4.14"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
20
public/404.html
Normal file
20
public/404.html
Normal 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>
|
||||
338
src/App.vue
338
src/App.vue
@@ -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">卡片结构按页面职责分化,但共用统一的 spacing、radius、shadow 和 interactive feedback。</p>
|
||||
</div>
|
||||
|
||||
<div class="bl-grid bl-grid-2 cards-grid">
|
||||
<TownCard v-for="town in townItems" :key="town.id" :town="town" @click="activeTown = town; townModalOpen = true" />
|
||||
</div>
|
||||
|
||||
<div class="bl-grid bl-grid-2 cards-grid">
|
||||
<LeaderboardCard v-for="board in leaderboardBoards" :key="board.title" :board="board" />
|
||||
</div>
|
||||
|
||||
<div class="bl-grid bl-grid-4 cards-grid">
|
||||
<PlayerCard v-for="player in playerItems" :key="player.id" :player="player" @click="activePlayer = player; playerModalOpen = true" />
|
||||
</div>
|
||||
|
||||
<div class="bl-grid bl-grid-2 cards-grid">
|
||||
<DonationCard v-for="donation in donationItems" :key="`${donation.name}-${donation.time}`" :donation="donation" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="showcase-section">
|
||||
<div class="bl-section-heading">
|
||||
<div>
|
||||
<p class="showcase-kicker">Announcements</p>
|
||||
<h2 class="bl-section-title">公告时间线与展开卡片</h2>
|
||||
</div>
|
||||
<p class="bl-section-copy">保留时间线为专用模式,不强行套进设施 / 城镇列表卡布局。</p>
|
||||
</div>
|
||||
|
||||
<AnnouncementTimeline :items="announcementItems" />
|
||||
</section>
|
||||
|
||||
<section class="showcase-section">
|
||||
<div class="bl-section-heading">
|
||||
<div>
|
||||
<p class="showcase-kicker">Home</p>
|
||||
<h2 class="bl-section-title">首页 Bento 特性栅格</h2>
|
||||
</div>
|
||||
<p class="bl-section-copy">视觉延续旧首页的功能块式布局,但用统一组件和 data-driven 结构输出。</p>
|
||||
</div>
|
||||
|
||||
<FeatureBentoGrid :items="bentoItems" />
|
||||
</section>
|
||||
|
||||
<section class="showcase-section">
|
||||
<div class="bl-section-heading">
|
||||
<div>
|
||||
<p class="showcase-kicker">Join</p>
|
||||
<h2 class="bl-section-title">加入游戏向导</h2>
|
||||
</div>
|
||||
<p class="bl-section-copy">保留 Join 页面纵向步骤与选择卡片关系,但迁移为可复用状态组件。</p>
|
||||
</div>
|
||||
|
||||
<JoinWizard :devices="joinDevices" :playstyles="playstyles" />
|
||||
</section>
|
||||
|
||||
<section class="showcase-section">
|
||||
<div class="bl-section-heading">
|
||||
<div>
|
||||
<p class="showcase-kicker">Detail</p>
|
||||
<h2 class="bl-section-title">详情弹窗审查入口</h2>
|
||||
</div>
|
||||
<p class="bl-section-copy">详情弹窗以 facilities / towns 的 modal 结构为基底,stats / sponsor 作为特化实现。</p>
|
||||
</div>
|
||||
|
||||
<BaseCard class="modal-launcher">
|
||||
<div class="button-row">
|
||||
<BaseButton @click="facilityModalOpen = true">查看设施详情</BaseButton>
|
||||
<BaseButton variant="secondary" @click="townModalOpen = true">查看城镇详情</BaseButton>
|
||||
<BaseButton variant="ghost" @click="playerModalOpen = true">查看玩家详情</BaseButton>
|
||||
<BaseButton variant="soft" @click="sponsorModalOpen = true">查看赞助弹窗</BaseButton>
|
||||
</div>
|
||||
</BaseCard>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<SiteFooter />
|
||||
|
||||
<FacilityDetailModal v-model="facilityModalOpen" :facility="activeFacility" />
|
||||
<TownDetailModal v-model="townModalOpen" :town="activeTown" />
|
||||
<PlayerDetailModal v-model="playerModalOpen" :player="activePlayer" />
|
||||
<SponsorModal v-model="sponsorModalOpen" :summary="sponsorSummary" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.showcase-page {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.showcase-main {
|
||||
padding: 40px 0 0;
|
||||
}
|
||||
|
||||
.showcase-section {
|
||||
margin-top: 36px;
|
||||
}
|
||||
|
||||
.showcase-kicker {
|
||||
margin: 0 0 10px;
|
||||
color: var(--bl-accent);
|
||||
font-size: 0.84rem;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.hero-review-panel {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.button-row,
|
||||
.badge-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.cards-grid {
|
||||
margin-top: 22px;
|
||||
}
|
||||
|
||||
.modal-launcher {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.showcase-section :deep(h3) {
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 840px) {
|
||||
.showcase-main {
|
||||
padding-top: 24px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<SiteNavbar :items="navItems" :active-path="activePath" />
|
||||
<router-view />
|
||||
<SiteFooter v-if="!isIframePage" />
|
||||
</template>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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');
|
||||
507
src/pages/AnnouncementsPage.vue
Normal file
507
src/pages/AnnouncementsPage.vue
Normal 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
19
src/pages/DocPage.vue
Normal 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>
|
||||
536
src/pages/FacilitiesPage.vue
Normal file
536
src/pages/FacilitiesPage.vue
Normal 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
700
src/pages/HomePage.vue
Normal 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
990
src/pages/JoinPage.vue
Normal 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。若您的设备未越狱,请确保已启用 JIT(Just-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
19
src/pages/MapPage.vue
Normal 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
19
src/pages/PhotoPage.vue
Normal 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
512
src/pages/SponsorPage.vue
Normal 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
844
src/pages/StatsPage.vue
Normal 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
674
src/pages/TownsPage.vue
Normal 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
70
src/router.js
Normal 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;
|
||||
Reference in New Issue
Block a user