feat: enhance ModalSection and FacilitiesPage with icons and improved layout

This commit is contained in:
zhangyuheng
2026-03-18 16:35:33 +08:00
parent 9414ee58ee
commit 3097e47d80
6 changed files with 190 additions and 121 deletions

View File

@@ -4,6 +4,10 @@ defineProps({
type: String, type: String,
required: true, required: true,
}, },
icon: {
type: String,
default: '',
},
subtitle: { subtitle: {
type: String, type: String,
default: '', default: '',
@@ -13,10 +17,10 @@ defineProps({
<template> <template>
<section class="modal-section"> <section class="modal-section">
<div class="modal-section__head"> <h4 class="modal-section-title">
<h4>{{ title }}</h4> <i v-if="icon" :class="icon"></i>
<p v-if="subtitle">{{ subtitle }}</p> {{ title }}
</div> </h4>
<div class="modal-section__body"> <div class="modal-section__body">
<slot /> <slot />
</div> </div>
@@ -25,23 +29,28 @@ defineProps({
<style scoped> <style scoped>
.modal-section { .modal-section {
padding: 18px 20px; margin-top: 32px;
border-radius: 18px;
background: rgba(245, 245, 247, 0.8);
} }
.modal-section__head h4, .modal-section-title {
.modal-section__head p { font-size: 16px;
margin: 0; font-weight: 700;
margin: 0 0 16px;
color: var(--bl-text);
display: flex;
align-items: center;
gap: 10px;
border-left: 4px solid var(--bl-accent, #0071e3);
padding-left: 12px;
} }
.modal-section__head p { .modal-section-title i {
margin-top: 6px; color: var(--bl-accent, #0071e3);
color: var(--bl-text-secondary); width: 20px;
font-size: 0.85rem; text-align: center;
} }
.modal-section__body { .modal-section__body {
margin-top: 14px; margin-top: 0;
} }
</style> </style>

View File

@@ -1,88 +1,50 @@
<script setup> <script setup>
import { RouterLink } from 'vue-router'; defineProps({
const props = defineProps({
brand: { brand: {
type: String, type: String,
default: '白鹿原', default: '白鹿原',
}, },
year: {
type: Number,
default: new Date().getFullYear(),
},
}); });
const footerLinks = [
{ label: '文档', href: '/doc' },
{ label: '地图', href: '/map' },
{ label: '赞助', href: '/sponsor' },
];
</script> </script>
<template> <template>
<footer class="site-footer"> <footer class="site-footer">
<div class="site-footer__inner bl-shell"> <div class="site-footer__inner">
<div> <div class="footer-content">
<p class="site-footer__brand">{{ brand }}</p> <div class="footer-logo">{{ brand }}</div>
<p class="site-footer__copy">© {{ year }} {{ brand }} Minecraft 服务器</p> <p>&copy; 2026 {{ brand }} Minecraft 服务器.</p>
</div> </div>
<nav class="site-footer__links" aria-label="页脚导航">
<RouterLink v-for="link in footerLinks" :key="link.href" :to="link.href">{{ link.label }}</RouterLink>
</nav>
</div> </div>
</footer> </footer>
</template> </template>
<style scoped> <style scoped>
.site-footer { .site-footer {
margin-top: 80px; background: var(--bl-bg, #f5f5f7);
padding: 28px 0 36px; padding: 40px 0;
border-top: 1px solid rgba(0, 0, 0, 0.06); border-top: 1px solid #e5e5e5;
background: rgba(255, 255, 255, 0.55); font-size: 12px;
backdrop-filter: blur(16px); color: var(--bl-text-secondary);
} }
.site-footer__inner { .site-footer__inner {
display: flex; max-width: var(--bl-content-width, 1200px);
align-items: center; margin: 0 auto;
justify-content: space-between; padding: 0 16px;
gap: 20px;
} }
.site-footer__brand, .footer-content {
.site-footer__copy { text-align: center;
margin: 0;
} }
.site-footer__brand { .footer-logo {
font-size: 1.1rem; font-size: 1.1rem;
font-weight: 700; font-weight: 700;
} margin-bottom: 6px;
.site-footer__copy {
margin-top: 6px;
color: var(--bl-text-secondary);
}
.site-footer__links {
display: flex;
flex-wrap: wrap;
gap: 18px;
}
.site-footer__links a {
color: var(--bl-text-secondary);
text-decoration: none;
}
.site-footer__links a:hover {
color: var(--bl-text); color: var(--bl-text);
} }
@media (max-width: 720px) { .footer-content p {
.site-footer__inner { margin: 0;
flex-direction: column;
align-items: flex-start;
}
} }
</style> </style>

View File

@@ -53,7 +53,7 @@ function onOverlayClick(e) {
.editor-overlay { .editor-overlay {
position: fixed; position: fixed;
inset: 0; inset: 0;
z-index: 1000; z-index: 2000;
background: rgba(0,0,0,0.5); background: rgba(0,0,0,0.5);
backdrop-filter: blur(5px); backdrop-filter: blur(5px);
display: flex; display: flex;

View File

@@ -2,7 +2,6 @@
import { ref, computed, onMounted, nextTick } from 'vue'; import { ref, computed, onMounted, nextTick } from 'vue';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import FilterPanel from '../components/shared/FilterPanel.vue'; import FilterPanel from '../components/shared/FilterPanel.vue';
import BaseBadge from '../components/base/BaseBadge.vue';
import BaseModal from '../components/base/BaseModal.vue'; import BaseModal from '../components/base/BaseModal.vue';
import ModalSection from '../components/detail/ModalSection.vue'; import ModalSection from '../components/detail/ModalSection.vue';
import EmptyState from '../components/base/EmptyState.vue'; import EmptyState from '../components/base/EmptyState.vue';
@@ -84,6 +83,7 @@ const typeTextMap = { resource: '资源', xp: '经验', infrastructure: '基建'
const dimensionTextMap = { overworld: '主世界', nether: '下界', end: '末地' }; const dimensionTextMap = { overworld: '主世界', nether: '下界', end: '末地' };
const statusTextMap = { online: '运行中', maintenance: '维护中', offline: '已停用' }; const statusTextMap = { online: '运行中', maintenance: '维护中', offline: '已停用' };
const statusToneMap = { online: 'success', maintenance: 'warning', offline: 'danger' }; const statusToneMap = { online: 'success', maintenance: 'warning', offline: 'danger' };
const statusIconMap = { online: 'fa-check-circle', maintenance: 'fa-wrench', offline: 'fa-times-circle' };
const filtered = computed(() => { const filtered = computed(() => {
return facilities.value.filter(item => { return facilities.value.filter(item => {
@@ -306,9 +306,10 @@ function generateJson() {
> >
<div class="card-header"> <div class="card-header">
<h3 class="card-title">{{ item.title }}</h3> <h3 class="card-title">{{ item.title }}</h3>
<BaseBadge :tone="statusToneMap[item.status] || 'neutral'"> <div :class="['status-indicator-badge', 'status-' + item.status]">
{{ statusTextMap[item.status] || item.status }} <div class="status-dot"></div>
</BaseBadge> <span>{{ statusTextMap[item.status] || item.status }}</span>
</div>
</div> </div>
<p class="card-intro">{{ item.intro }}</p> <p class="card-intro">{{ item.intro }}</p>
<div class="card-meta"> <div class="card-meta">
@@ -329,9 +330,11 @@ function generateJson() {
<div class="modal-badges-row"> <div class="modal-badges-row">
<div class="modal-badges"> <div class="modal-badges">
<span :class="['badge', 'large-badge', 'badge-status-' + selectedFacility.status]"> <span :class="['badge', 'large-badge', 'badge-status-' + selectedFacility.status]">
<i class="fas" :class="statusIconMap[selectedFacility.status]"></i>
{{ statusTextMap[selectedFacility.status] }} {{ statusTextMap[selectedFacility.status] }}
</span> </span>
<span class="badge large-badge badge-type"> <span class="badge large-badge badge-type">
<i class="fas fa-cube"></i>
{{ typeTextMap[selectedFacility.type] }} {{ typeTextMap[selectedFacility.type] }}
</span> </span>
</div> </div>
@@ -352,7 +355,7 @@ function generateJson() {
</template> </template>
<template v-if="selectedFacility"> <template v-if="selectedFacility">
<ModalSection title="位置信息"> <ModalSection title="位置信息" icon="fas fa-map-marker-alt">
<p> <p>
{{ dimensionTextMap[selectedFacility.dimension] }} {{ dimensionTextMap[selectedFacility.dimension] }}
<template v-if="selectedFacility.coordinates"> <template v-if="selectedFacility.coordinates">
@@ -370,7 +373,7 @@ function generateJson() {
</p> </p>
</ModalSection> </ModalSection>
<ModalSection v-if="selectedFacility.contributors?.length" title="贡献 / 维护人员"> <ModalSection v-if="selectedFacility.contributors?.length" title="贡献 / 维护人员" icon="fas fa-users-cog">
<div class="contributors-list"> <div class="contributors-list">
<span v-for="name in selectedFacility.contributors" :key="name" class="contributor-tag"> <span v-for="name in selectedFacility.contributors" :key="name" class="contributor-tag">
<img :src="`https://minotar.net/avatar/${name}/20`" :alt="name" loading="lazy"> <img :src="`https://minotar.net/avatar/${name}/20`" :alt="name" loading="lazy">
@@ -379,7 +382,7 @@ function generateJson() {
</div> </div>
</ModalSection> </ModalSection>
<ModalSection v-if="selectedFacility.instructions?.length" title="使用说明"> <ModalSection v-if="selectedFacility.instructions?.length" title="使用说明" icon="fas fa-book-open">
<div class="content-blocks"> <div class="content-blocks">
<template v-for="(block, bi) in selectedFacility.instructions" :key="bi"> <template v-for="(block, bi) in selectedFacility.instructions" :key="bi">
<p v-if="block.type === 'text'">{{ block.content }}</p> <p v-if="block.type === 'text'">{{ block.content }}</p>
@@ -396,7 +399,7 @@ function generateJson() {
</div> </div>
</ModalSection> </ModalSection>
<ModalSection v-if="selectedFacility.notes?.length" title="注意事项"> <ModalSection v-if="selectedFacility.notes?.length" title="注意事项" icon="fas fa-exclamation-triangle">
<div class="content-blocks"> <div class="content-blocks">
<template v-for="(block, bi) in selectedFacility.notes" :key="bi"> <template v-for="(block, bi) in selectedFacility.notes" :key="bi">
<p v-if="block.type === 'text'">{{ block.content }}</p> <p v-if="block.type === 'text'">{{ block.content }}</p>
@@ -422,8 +425,8 @@ function generateJson() {
<div class="preview-header"> <div class="preview-header">
<div class="preview-title">{{ edTitle || '未命名设施' }}</div> <div class="preview-title">{{ edTitle || '未命名设施' }}</div>
<div class="modal-badges"> <div class="modal-badges">
<span :class="['badge', 'large-badge', 'badge-status-' + edStatus]">{{ statusTextMap[edStatus] }}</span> <span :class="['badge', 'large-badge', 'badge-status-' + edStatus]"><i class="fas" :class="statusIconMap[edStatus]"></i> {{ statusTextMap[edStatus] }}</span>
<span class="badge large-badge badge-type">{{ typeTextMap[edType] }}</span> <span class="badge large-badge badge-type"><i class="fas fa-cube"></i> {{ typeTextMap[edType] }}</span>
</div> </div>
</div> </div>
<div class="preview-body"> <div class="preview-body">
@@ -687,6 +690,29 @@ function generateJson() {
line-height: 1.3; line-height: 1.3;
} }
.status-indicator-badge {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
font-weight: 600;
padding: 4px 8px;
border-radius: 12px;
white-space: nowrap;
}
.status-indicator-badge.status-online { background-color: #e8fceb; color: #15803d; }
.status-indicator-badge.status-maintenance { background-color: #fff8d6; color: #b45309; }
.status-indicator-badge.status-offline { background-color: #feebeb; color: #b91c1c; }
.status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
}
.status-online .status-dot { background-color: #22c55e; }
.status-maintenance .status-dot { background-color: #f59e0b; }
.status-offline .status-dot { background-color: #ef4444; }
.card-intro { .card-intro {
font-size: 14px; font-size: 14px;
color: var(--bl-text-secondary); color: var(--bl-text-secondary);

View File

@@ -11,6 +11,7 @@ const projectFilter = ref('all');
const modalOpen = ref(false); const modalOpen = ref(false);
const isMobile = ref(false); const isMobile = ref(false);
const qrLoaded = ref(false);
onMounted(() => { onMounted(() => {
isMobile.value = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent) || window.innerWidth < 768; isMobile.value = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent) || window.innerWidth < 768;
@@ -167,10 +168,16 @@ function setProject(p) {
<!-- Desktop QR --> <!-- Desktop QR -->
<div v-if="!isMobile" class="desktop-qr-view"> <div v-if="!isMobile" class="desktop-qr-view">
<div class="qr-placeholder"> <div class="qr-placeholder">
<div v-if="!qrLoaded" class="qr-loading">
<i class="fas fa-spinner fa-spin"></i>
<span>加载中</span>
</div>
<img <img
src="https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=https%3A%2F%2Fqr.alipay.com%2F2cz0344fnaulnbybhp04" src="https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=https%3A%2F%2Fqr.alipay.com%2F2cz0344fnaulnbybhp04"
alt="支付宝二维码" alt="支付宝二维码"
class="qr-img" class="qr-img"
:style="{ display: qrLoaded ? 'block' : 'none' }"
@load="qrLoaded = true"
> >
</div> </div>
<p class="desktop-qr-hint">推荐使用支付宝扫码</p> <p class="desktop-qr-hint">推荐使用支付宝扫码</p>
@@ -549,6 +556,25 @@ function setProject(p) {
background: #fff; background: #fff;
border-radius: 16px; border-radius: 16px;
border: 1px solid rgba(0, 0, 0, 0.05); border: 1px solid rgba(0, 0, 0, 0.05);
min-width: 232px;
min-height: 232px;
}
.qr-loading {
width: 200px;
height: 200px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
color: var(--bl-text-secondary);
font-size: 14px;
}
.qr-loading i {
font-size: 24px;
color: var(--bl-accent);
} }
.qr-img { .qr-img {

View File

@@ -443,7 +443,7 @@ function generateJson() {
<template v-if="selectedTown"> <template v-if="selectedTown">
<!-- Location --> <!-- Location -->
<ModalSection title="位置信息"> <ModalSection title="位置信息" icon="fas fa-map-marker-alt">
<p v-if="selectedTown.coordinatesSecret">保密</p> <p v-if="selectedTown.coordinatesSecret">保密</p>
<p v-else> <p v-else>
{{ dimensionTextMap[selectedTown.dimension] || '主世界' }} {{ dimensionTextMap[selectedTown.dimension] || '主世界' }}
@@ -463,7 +463,7 @@ function generateJson() {
</ModalSection> </ModalSection>
<!-- Founders --> <!-- Founders -->
<ModalSection title="创始人"> <ModalSection title="创始人" icon="fas fa-crown">
<div v-if="selectedTown.founders?.length" class="contributors-list"> <div v-if="selectedTown.founders?.length" class="contributors-list">
<span v-for="name in selectedTown.founders" :key="name" class="contributor-tag"> <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"> <img :src="`https://minotar.net/avatar/${encodeURIComponent(name)}/20`" :alt="name" loading="lazy">
@@ -474,7 +474,7 @@ function generateJson() {
</ModalSection> </ModalSection>
<!-- Members --> <!-- Members -->
<ModalSection title="成员"> <ModalSection title="成员" icon="fas fa-users">
<div v-if="selectedTown.members?.length" class="contributors-list"> <div v-if="selectedTown.members?.length" class="contributors-list">
<span v-for="name in selectedTown.members" :key="name" class="contributor-tag"> <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"> <img :src="`https://minotar.net/avatar/${encodeURIComponent(name)}/20`" :alt="name" loading="lazy">
@@ -485,7 +485,7 @@ function generateJson() {
</ModalSection> </ModalSection>
<!-- Introduction --> <!-- Introduction -->
<ModalSection v-if="selectedTown.introduction?.length" title="城镇介绍"> <ModalSection v-if="selectedTown.introduction?.length" title="城镇介绍" icon="fas fa-scroll">
<div class="content-blocks"> <div class="content-blocks">
<template v-for="(block, bi) in selectedTown.introduction" :key="bi"> <template v-for="(block, bi) in selectedTown.introduction" :key="bi">
<p v-if="block.type === 'text'">{{ block.content }}</p> <p v-if="block.type === 'text'">{{ block.content }}</p>
@@ -623,10 +623,13 @@ function generateJson() {
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="toggle-label"> <label class="toggle-label">
<input type="checkbox" v-model="edSecret" class="toggle-checkbox"> <span>坐标保密</span>
<span class="toggle-switch"></span> <div class="toggle-switch">
坐标保密 <input type="checkbox" v-model="edSecret">
<span class="toggle-slider"></span>
</div>
</label> </label>
<p class="field-hint">开启后将隐藏坐标信息适用于不希望公开位置的城镇</p>
</div> </div>
<template v-if="!edSecret"> <template v-if="!edSecret">
<div class="form-group"> <div class="form-group">
@@ -782,13 +785,22 @@ function generateJson() {
} }
.town-card-bg { .town-card-bg {
height: 180px; height: 140px;
background-size: cover; background-size: cover;
background-position: center; background-position: center;
position: relative; position: relative;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background-color: #e8ecf1;
}
.town-card-bg::after {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(to top, rgba(15, 23, 42, 0.28), transparent 55%);
pointer-events: none;
} }
.town-card-bg.no-logo { .town-card-bg.no-logo {
@@ -802,27 +814,40 @@ function generateJson() {
.town-card-icons { .town-card-icons {
position: absolute; position: absolute;
bottom: 10px; bottom: -14px;
right: 10px; left: 16px;
display: flex; display: flex;
gap: 6px; gap: 8px;
z-index: 2;
} }
.town-icon-badge { .town-icon-badge {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 30px; width: 28px;
height: 30px; height: 28px;
border-radius: 50%; border-radius: 50%;
background: rgba(0, 0, 0, 0.45);
color: #fff; color: #fff;
font-size: 13px; font-size: 12px;
backdrop-filter: blur(4px); box-shadow: 0 2px 8px rgba(0,0,0,0.15);
border: 2px solid #fff;
} }
.icon-scale-small { background: #60a5fa; }
.icon-scale-medium { background: #f59e0b; }
.icon-scale-large { background: #ef4444; }
.icon-type-building { background: #8b5cf6; }
.icon-type-adventure { background: #10b981; }
.icon-type-industry { background: #f97316; }
.icon-recruit-welcome { background: #22c55e; }
.icon-recruit-closed { background: #ef4444; }
.icon-recruit-maybe { background: #eab308; }
.town-card-body { .town-card-body {
padding: 18px 20px; padding: 24px 20px 20px;
} }
.town-card-title { .town-card-title {
@@ -835,6 +860,9 @@ function generateJson() {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 8px; gap: 8px;
margin-top: auto;
padding-top: 12px;
border-top: 1px solid #f0f0f0;
} }
.town-meta-tag { .town-meta-tag {
@@ -1106,49 +1134,67 @@ function generateJson() {
box-sizing: border-box; box-sizing: border-box;
} }
.toggle-label { .form-group .toggle-label {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; justify-content: space-between;
cursor: pointer;
font-size: 14px;
font-weight: 600; font-weight: 600;
font-size: 14px;
color: var(--bl-text); color: var(--bl-text);
user-select: none; cursor: pointer;
} text-transform: none;
letter-spacing: normal;
.toggle-checkbox { margin-bottom: 0;
display: none;
} }
.toggle-switch { .toggle-switch {
position: relative; position: relative;
width: 44px; width: 44px;
height: 24px; height: 24px;
background: #ccc;
border-radius: 12px;
transition: 0.3s;
flex-shrink: 0; flex-shrink: 0;
} }
.toggle-switch::after { .toggle-switch input {
content: ''; opacity: 0;
width: 0;
height: 0;
position: absolute; position: absolute;
top: 2px; }
left: 2px;
width: 20px; .toggle-slider {
height: 20px; position: absolute;
inset: 0;
background: #ccc;
border-radius: 24px;
transition: 0.25s;
cursor: pointer;
}
.toggle-slider::before {
content: "";
position: absolute;
width: 18px;
height: 18px;
left: 3px;
bottom: 3px;
background: #fff; background: #fff;
border-radius: 50%; border-radius: 50%;
transition: 0.3s; transition: 0.25s;
box-shadow: 0 1px 3px rgba(0,0,0,0.2); box-shadow: 0 1px 3px rgba(0,0,0,0.2);
} }
.toggle-checkbox:checked + .toggle-switch { .toggle-switch input:checked + .toggle-slider {
background: var(--bl-accent); background: var(--bl-accent);
} }
.toggle-checkbox:checked + .toggle-switch::after { .toggle-switch input:checked + .toggle-slider::before {
transform: translateX(20px); transform: translateX(20px);
} }
.field-hint {
font-size: 12px;
color: var(--bl-text-secondary);
margin-top: 6px;
line-height: 1.4;
}
</style> </style>