mirror of
https://github.com/Coldsmiles/infstarweb.git
synced 2026-04-23 02:30:41 +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."
|
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 UI Migration"
|
name: "Vue Legacy Page Parity Migration"
|
||||||
applyTo: "src/**/*.vue, src/**/*.js, src/**/*.css"
|
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
|
## Goal
|
||||||
|
|
||||||
- Rebuild the legacy pages in `old-html-ver/` with Vue 3 components in `src/`.
|
- Replace the temporary Vue component review shell with the real site.
|
||||||
- Do not use external UI component libraries.
|
- Migrate every page under `old-html-ver/` to Vue.
|
||||||
- Recreate the site's visual language from the legacy HTML/CSS, but normalize duplicated styles into a smaller, cleaner component system.
|
- Preserve the legacy site's layout, design, copy, interaction details, responsive behavior, and externally visible navigation behavior.
|
||||||
- When similar legacy UIs use different implementations, keep the strongest pattern and discard weaker duplicates.
|
- 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.
|
- Migrate with exact page parity. Do not redesign, modernize, normalize, simplify, or reinterpret the old site on your own.
|
||||||
- Treat `old-html-ver/css/style.css` as the source of truth for global tokens and shared layout behavior.
|
- 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.
|
||||||
- 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.
|
- Do not keep `src/App.vue` as a component gallery or leave `src/demoData.js` in the real page flow.
|
||||||
- Treat `old-html-ver/index.html` as the source for the home-page-only bento grid and hero interaction patterns.
|
- Do not replace production-like data with mock cards, placeholder lists, or hand-written demo objects once a page is being migrated.
|
||||||
- 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.
|
- 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:
|
- `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.
|
||||||
- card-like `controls-section`
|
- `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.
|
||||||
- `search-box` with leading icon
|
- `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.
|
||||||
- labeled `filter-group`
|
- `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.
|
||||||
- pill `filter-tag` buttons with a strong active state
|
|
||||||
- Prefer the facilities/towns card and modal structure as the base pattern for data-detail pages.
|
|
||||||
- Keep the announcement timeline as a dedicated page pattern instead of forcing it into the facilities/towns card layout.
|
|
||||||
- Keep leaderboard cards, donation cards, and join wizard cards as specialized components that inherit shared spacing, radius, shadow, and button rules from base UI primitives.
|
|
||||||
- If a sponsor or stats page control differs from the announcements/facilities/towns version, default to the latter unless the page has a real functional need for a custom variant.
|
|
||||||
|
|
||||||
## Components To Extract First
|
## Source Of Truth
|
||||||
|
|
||||||
- Layout primitives:
|
- Shared layout and global visual system:
|
||||||
- `SiteNavbar`
|
- `old-html-ver/js/components.js`
|
||||||
- `MobileNavDrawer`
|
- `old-html-ver/css/style.css`
|
||||||
- `PageHero`
|
- Page-specific HTML structure and page head metadata:
|
||||||
- `SiteFooter`
|
- `old-html-ver/*.html`
|
||||||
- Base UI primitives:
|
- Page-specific interactions:
|
||||||
- `BaseButton`
|
- `old-html-ver/js/*.js`
|
||||||
- `BaseCard`
|
- Page-specific styles:
|
||||||
- `BaseModal`
|
- `old-html-ver/css/pages/*.css`
|
||||||
- `BaseBadge`
|
- Runtime data contracts:
|
||||||
- `SearchBox`
|
- `public/data/*.json`
|
||||||
- `FilterTagGroup`
|
- `public/data/*.txt`
|
||||||
- `EmptyState`
|
- `public/stats/summary.json`
|
||||||
- `LoadMoreButton`
|
- `public/stats/*.json`
|
||||||
- Shared content components:
|
|
||||||
- `FilterPanel`
|
|
||||||
- `FacilityCard`
|
|
||||||
- `TownCard`
|
|
||||||
- `AnnouncementTimeline`
|
|
||||||
- `AnnouncementCard`
|
|
||||||
- `LeaderboardCard`
|
|
||||||
- `PlayerCard`
|
|
||||||
- `DonationCard`
|
|
||||||
- `FeatureBentoGrid`
|
|
||||||
- `FeatureBentoCard`
|
|
||||||
- `JoinWizard`
|
|
||||||
- `DeviceCard`
|
|
||||||
- `PlaystyleCard`
|
|
||||||
- Detail components:
|
|
||||||
- `FacilityDetailModal`
|
|
||||||
- `TownDetailModal`
|
|
||||||
- `PlayerDetailModal`
|
|
||||||
- `SponsorModal`
|
|
||||||
- `ModalSection`
|
|
||||||
|
|
||||||
## Expected Component Responsibilities
|
## Shared Legacy Primitives To Preserve
|
||||||
|
|
||||||
- Shared components must be prop-driven and reusable across multiple pages.
|
- Navbar, mobile menu, and footer come from `old-html-ver/js/components.js`.
|
||||||
- Page components should compose shared components instead of duplicating old HTML blocks.
|
- The reusable page hero pattern comes from `old-html-ver/js/components.js` plus `old-html-ver/css/style.css`.
|
||||||
- Data-driven sections should accept structured props for badges, coordinates, contributor lists, rich text blocks, media blocks, and status labels.
|
- The translucent fixed top navigation, 44px header offset, rounded cards, soft shadows, and hero overlays come from `old-html-ver/css/style.css`.
|
||||||
- Repeated filter and search logic should move into Vue composables instead of being reimplemented in each page.
|
- 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
|
## 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.
|
- Preserve the legacy CSS variable language and visual rhythm from `old-html-ver/css/style.css`.
|
||||||
- Reuse the legacy CSS variable vocabulary where it still makes sense, but consolidate it in the Vue codebase instead of copying page CSS wholesale.
|
- Match legacy spacing, card density, section order, control grouping, icon usage, and breakpoint behavior.
|
||||||
- Do not copy-paste duplicated legacy class trees unless they are the chosen canonical pattern.
|
- Preserve hover states, animation cadence, focus states, and modal open-close feel where they are visible to users.
|
||||||
- Normalize spacing, radii, shadows, and interactive states across components.
|
- Avoid large global style rewrites before parity is reached.
|
||||||
- Preserve responsive behavior from the legacy site, especially navbar/mobile menu, hero scaling, filter wrapping, grid collapse, and modal usability.
|
|
||||||
- When creating styles, prefer local component styles or clearly organized shared styles over page-specific one-off overrides.
|
|
||||||
|
|
||||||
## Architecture Rules For Vue Work
|
## Interaction And Data Rules
|
||||||
|
|
||||||
- Build shared primitives before page-specific wrappers.
|
- Prefer Vue state and template bindings over direct DOM mutation, but keep the visible behavior identical.
|
||||||
- Use slots only when content structure truly varies; otherwise use typed props with clear names.
|
- Keep fetch paths relative for local files.
|
||||||
- Keep interaction state inside the component or a focused composable such as search, filtering, modal visibility, and pagination.
|
- Maintain current external integrations:
|
||||||
- Avoid direct DOM manipulation when Vue state and template bindings can express the behavior.
|
- `https://api.mcstatus.io/v2/status/java/mcpure.lunadeer.cn`
|
||||||
- Preserve current data contracts from `public/data/` and `public/stats/` unless the task explicitly includes changing them.
|
- `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.
|
1. Replace the review shell architecture so the project can serve legacy page URLs.
|
||||||
- Announcements page -> `FilterPanel` + `AnnouncementTimeline` + expandable `AnnouncementCard`.
|
2. Wire shared layout pieces to match `old-html-ver/js/components.js` and `old-html-ver/css/style.css` precisely.
|
||||||
- Facilities page -> `FilterPanel` + `FacilityCard` grid + `FacilityDetailModal`.
|
3. Migrate the simple iframe pages: `doc.html`, `map.html`, `photo.html`.
|
||||||
- Towns page -> `FilterPanel` + `TownCard` grid + `TownDetailModal`.
|
4. Migrate the filter-and-modal data pages: announcements, facilities, towns.
|
||||||
- Stats page -> `LeaderboardCard` grid + `PlayerCard` grid + `PlayerDetailModal` + pagination controls.
|
5. Migrate sponsor and stats using the real public data files.
|
||||||
- Sponsor page -> `DonationCard` grid + `SponsorModal`, while still reusing shared search and filter primitives.
|
6. Migrate the join wizard with markdown rendering.
|
||||||
- Join page -> `JoinWizard`, `ProgressStep`, `DeviceCard`, `EditionToggle`, `PlaystyleCard`.
|
7. Migrate the home page with live status, timers, sponsor totals, and crowdfunding.
|
||||||
- Home page -> `PageHero` specialization + `FeatureBentoGrid` + sponsor highlight section.
|
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.
|
- Compare new and old pages side by side on desktop and mobile.
|
||||||
- Do not keep one Vue component per old HTML page section if the section is really a shared pattern.
|
- Check that page order, section order, and copy match.
|
||||||
- Do not preserve inconsistent legacy styling just because it already exists.
|
- Check that filters, searches, empty states, and modal behavior match.
|
||||||
- Do not port legacy imperative JavaScript event wiring directly into Vue components.
|
- Check deep links for announcements, facilities, and towns, including direct access that auto-opens the correct item or modal.
|
||||||
- Do not silently invent a new visual language that ignores the old site structure and tone.
|
- 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.
|
- State the exact legacy page being migrated.
|
||||||
- For search and filtering, bias toward the announcements/facilities/towns implementation.
|
- List the specific legacy files used as the source of truth.
|
||||||
- For detail dialogs, bias toward the facilities/towns modal structure.
|
- Explain which existing Vue components are being reused and why they still allow exact parity.
|
||||||
- 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.
|
- 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.
|
||||||
## 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.
|
|
||||||
97
index.html
97
index.html
@@ -3,11 +3,102 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="description" content="白鹿原 Minecraft 服务器官网 Vue 重构工程。" />
|
<title>白鹿原 Minecraft 服务器 - 永不换档的纯净原版生存Minecraft服务器</title>
|
||||||
<title>白鹿原 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>
|
</head>
|
||||||
<body>
|
<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>
|
<div id="app"></div>
|
||||||
<script type="module" src="/src/main.js"></script>
|
<script type="module" src="/src/main.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
37
package-lock.json
generated
37
package-lock.json
generated
@@ -8,7 +8,9 @@
|
|||||||
"name": "bailuyuan-web",
|
"name": "bailuyuan-web",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"vue": "^3.5.13"
|
"marked": "^17.0.4",
|
||||||
|
"vue": "^3.5.13",
|
||||||
|
"vue-router": "^4.6.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-vue": "^5.2.1",
|
"@vitejs/plugin-vue": "^5.2.1",
|
||||||
@@ -879,6 +881,12 @@
|
|||||||
"@vue/shared": "3.5.30"
|
"@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": {
|
"node_modules/@vue/reactivity": {
|
||||||
"version": "3.5.30",
|
"version": "3.5.30",
|
||||||
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.30.tgz",
|
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.30.tgz",
|
||||||
@@ -1016,6 +1024,18 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
"@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": {
|
"node_modules/nanoid": {
|
||||||
"version": "3.3.11",
|
"version": "3.3.11",
|
||||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
||||||
@@ -1202,6 +1222,21 @@
|
|||||||
"optional": true
|
"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,7 +10,9 @@
|
|||||||
"update:stats": "python scripts/statsprocess.py"
|
"update:stats": "python scripts/statsprocess.py"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"vue": "^3.5.13"
|
"marked": "^17.0.4",
|
||||||
|
"vue": "^3.5.13",
|
||||||
|
"vue-router": "^4.6.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-vue": "^5.2.1",
|
"@vitejs/plugin-vue": "^5.2.1",
|
||||||
|
|||||||
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>
|
||||||
336
src/App.vue
336
src/App.vue
@@ -1,322 +1,34 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, ref } from 'vue';
|
import { computed } from 'vue';
|
||||||
import {
|
import { useRoute } from 'vue-router';
|
||||||
AnnouncementTimeline,
|
import SiteNavbar from './components/layout/SiteNavbar.vue';
|
||||||
BaseBadge,
|
import SiteFooter from './components/layout/SiteFooter.vue';
|
||||||
BaseButton,
|
|
||||||
BaseCard,
|
|
||||||
EmptyState,
|
|
||||||
FacilityCard,
|
|
||||||
FacilityDetailModal,
|
|
||||||
FeatureBentoGrid,
|
|
||||||
FilterPanel,
|
|
||||||
JoinWizard,
|
|
||||||
LeaderboardCard,
|
|
||||||
LoadMoreButton,
|
|
||||||
PageHero,
|
|
||||||
PlayerCard,
|
|
||||||
PlayerDetailModal,
|
|
||||||
SiteFooter,
|
|
||||||
SiteNavbar,
|
|
||||||
SponsorModal,
|
|
||||||
TownCard,
|
|
||||||
TownDetailModal,
|
|
||||||
DonationCard,
|
|
||||||
} from './components';
|
|
||||||
import {
|
|
||||||
announcementItems,
|
|
||||||
bentoItems,
|
|
||||||
donationItems,
|
|
||||||
facilityItems,
|
|
||||||
joinDevices,
|
|
||||||
leaderboardBoards,
|
|
||||||
navItems,
|
|
||||||
playerItems,
|
|
||||||
playstyles,
|
|
||||||
sponsorSummary,
|
|
||||||
townItems,
|
|
||||||
} from './demoData';
|
|
||||||
|
|
||||||
const searchValue = ref('');
|
const route = useRoute();
|
||||||
const selectedFacilityType = ref('all');
|
|
||||||
const selectedFacilityDimension = ref('all');
|
|
||||||
|
|
||||||
const facilityModalOpen = ref(false);
|
const navItems = [
|
||||||
const townModalOpen = ref(false);
|
{ label: '文档', href: '/doc' },
|
||||||
const playerModalOpen = ref(false);
|
{ label: '地图', href: '/map' },
|
||||||
const sponsorModalOpen = ref(false);
|
{ 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 activePath = computed(() => route.path);
|
||||||
const activeTown = ref(townItems[0]);
|
|
||||||
const activePlayer = ref(playerItems[0]);
|
|
||||||
|
|
||||||
const filters = computed(() => [
|
// iframe pages don't show footer; they fill the viewport
|
||||||
{
|
const isIframePage = computed(() =>
|
||||||
key: 'type',
|
['/doc', '/map', '/photo'].includes(route.path)
|
||||||
label: '类型',
|
);
|
||||||
modelValue: selectedFacilityType.value,
|
|
||||||
options: [
|
|
||||||
{ value: 'all', label: '全部' },
|
|
||||||
{ value: '资源', label: '资源', icon: '◈' },
|
|
||||||
{ value: '基建', label: '基建', icon: '▤' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'dimension',
|
|
||||||
label: '维度',
|
|
||||||
modelValue: selectedFacilityDimension.value,
|
|
||||||
options: [
|
|
||||||
{ value: 'all', label: '全部' },
|
|
||||||
{ value: '主世界', label: '主世界' },
|
|
||||||
{ value: '下界', label: '下界' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
const filteredFacilities = computed(() => {
|
|
||||||
const keyword = searchValue.value.trim().toLowerCase();
|
|
||||||
|
|
||||||
return facilityItems.filter((item) => {
|
|
||||||
const matchesKeyword = !keyword || `${item.title} ${item.intro}`.toLowerCase().includes(keyword);
|
|
||||||
const matchesType = selectedFacilityType.value === 'all' || item.type === selectedFacilityType.value;
|
|
||||||
const matchesDimension = selectedFacilityDimension.value === 'all' || item.dimension === selectedFacilityDimension.value;
|
|
||||||
return matchesKeyword && matchesType && matchesDimension;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleFilterChange = ({ key, value }) => {
|
|
||||||
if (key === 'type') {
|
|
||||||
selectedFacilityType.value = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (key === 'dimension') {
|
|
||||||
selectedFacilityDimension.value = value;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="showcase-page">
|
<SiteNavbar :items="navItems" :active-path="activePath" />
|
||||||
<SiteNavbar :items="navItems" active-path="/facilities.html" />
|
<router-view />
|
||||||
|
<SiteFooter v-if="!isIframePage" />
|
||||||
<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>
|
</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>
|
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
|
import { RouterLink } from 'vue-router';
|
||||||
|
|
||||||
defineProps({
|
defineProps({
|
||||||
open: {
|
open: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
@@ -32,18 +34,28 @@ const emit = defineEmits(['close']);
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<nav class="mobile-drawer__links" aria-label="移动端导航">
|
<nav class="mobile-drawer__links" aria-label="移动端导航">
|
||||||
|
<template v-for="item in items" :key="item.href">
|
||||||
<a
|
<a
|
||||||
v-for="item in items"
|
v-if="item.external"
|
||||||
:key="item.href"
|
|
||||||
class="mobile-drawer__link"
|
class="mobile-drawer__link"
|
||||||
:href="item.href"
|
:href="item.href"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
@click="emit('close')"
|
@click="emit('close')"
|
||||||
>
|
>
|
||||||
<span>{{ item.label }}</span>
|
<span>{{ item.label }}</span>
|
||||||
<small v-if="item.description">{{ item.description }}</small>
|
|
||||||
</a>
|
</a>
|
||||||
|
<RouterLink
|
||||||
|
v-else
|
||||||
|
class="mobile-drawer__link"
|
||||||
|
:to="item.href"
|
||||||
|
@click="emit('close')"
|
||||||
|
>
|
||||||
|
<span>{{ item.label }}</span>
|
||||||
|
</RouterLink>
|
||||||
|
</template>
|
||||||
</nav>
|
</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>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
</transition>
|
</transition>
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
|
import { RouterLink } from 'vue-router';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
brand: {
|
brand: {
|
||||||
type: String,
|
type: String,
|
||||||
@@ -11,9 +13,9 @@ const props = defineProps({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const footerLinks = [
|
const footerLinks = [
|
||||||
{ label: '文档', href: '/doc.html' },
|
{ label: '文档', href: '/doc' },
|
||||||
{ label: '地图', href: '/map.html' },
|
{ label: '地图', href: '/map' },
|
||||||
{ label: '赞助', href: '/sponsor.html' },
|
{ label: '赞助', href: '/sponsor' },
|
||||||
];
|
];
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -25,7 +27,7 @@ const footerLinks = [
|
|||||||
<p class="site-footer__copy">© {{ year }} {{ brand }} Minecraft 服务器</p>
|
<p class="site-footer__copy">© {{ year }} {{ brand }} Minecraft 服务器</p>
|
||||||
</div>
|
</div>
|
||||||
<nav class="site-footer__links" aria-label="页脚导航">
|
<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>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
|
import { RouterLink } from 'vue-router';
|
||||||
import MobileNavDrawer from './MobileNavDrawer.vue';
|
import MobileNavDrawer from './MobileNavDrawer.vue';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -48,22 +49,28 @@ const isActive = (href) => href === props.activePath;
|
|||||||
<span></span>
|
<span></span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<a class="site-navbar__logo" href="/">
|
<RouterLink class="site-navbar__logo" to="/">
|
||||||
<img :src="logoSrc" :alt="logoAlt">
|
<img :src="logoSrc" :alt="logoAlt">
|
||||||
</a>
|
</RouterLink>
|
||||||
|
|
||||||
<nav class="site-navbar__links" aria-label="主导航">
|
<nav class="site-navbar__links" aria-label="主导航">
|
||||||
|
<template v-for="item in items" :key="item.href">
|
||||||
<a
|
<a
|
||||||
v-for="item in items"
|
v-if="item.external"
|
||||||
:key="item.href"
|
|
||||||
:href="item.href"
|
: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) }]"
|
:class="['site-navbar__link', { 'is-active': isActive(item.href) }]"
|
||||||
>
|
>{{ item.label }}</RouterLink>
|
||||||
{{ item.label }}
|
</template>
|
||||||
</a>
|
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<a class="site-navbar__cta" :href="ctaHref">{{ ctaLabel }}</a>
|
<RouterLink class="site-navbar__cta" :to="ctaHref">{{ ctaLabel }}</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createApp } from 'vue';
|
import { createApp } from 'vue';
|
||||||
import App from './App.vue';
|
import App from './App.vue';
|
||||||
|
import router from './router';
|
||||||
import './styles.css';
|
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