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

View File

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

View File

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

View File

@@ -2,7 +2,6 @@
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';
@@ -84,6 +83,7 @@ const typeTextMap = { resource: '资源', xp: '经验', infrastructure: '基建'
const dimensionTextMap = { overworld: '主世界', nether: '下界', end: '末地' };
const statusTextMap = { online: '运行中', maintenance: '维护中', offline: '已停用' };
const statusToneMap = { online: 'success', maintenance: 'warning', offline: 'danger' };
const statusIconMap = { online: 'fa-check-circle', maintenance: 'fa-wrench', offline: 'fa-times-circle' };
const filtered = computed(() => {
return facilities.value.filter(item => {
@@ -306,9 +306,10 @@ function generateJson() {
>
<div class="card-header">
<h3 class="card-title">{{ item.title }}</h3>
<BaseBadge :tone="statusToneMap[item.status] || 'neutral'">
{{ statusTextMap[item.status] || item.status }}
</BaseBadge>
<div :class="['status-indicator-badge', 'status-' + item.status]">
<div class="status-dot"></div>
<span>{{ statusTextMap[item.status] || item.status }}</span>
</div>
</div>
<p class="card-intro">{{ item.intro }}</p>
<div class="card-meta">
@@ -329,9 +330,11 @@ function generateJson() {
<div class="modal-badges-row">
<div class="modal-badges">
<span :class="['badge', 'large-badge', 'badge-status-' + selectedFacility.status]">
<i class="fas" :class="statusIconMap[selectedFacility.status]"></i>
{{ statusTextMap[selectedFacility.status] }}
</span>
<span class="badge large-badge badge-type">
<i class="fas fa-cube"></i>
{{ typeTextMap[selectedFacility.type] }}
</span>
</div>
@@ -352,7 +355,7 @@ function generateJson() {
</template>
<template v-if="selectedFacility">
<ModalSection title="位置信息">
<ModalSection title="位置信息" icon="fas fa-map-marker-alt">
<p>
{{ dimensionTextMap[selectedFacility.dimension] }}
<template v-if="selectedFacility.coordinates">
@@ -370,7 +373,7 @@ function generateJson() {
</p>
</ModalSection>
<ModalSection v-if="selectedFacility.contributors?.length" title="贡献 / 维护人员">
<ModalSection v-if="selectedFacility.contributors?.length" title="贡献 / 维护人员" icon="fas fa-users-cog">
<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">
@@ -379,7 +382,7 @@ function generateJson() {
</div>
</ModalSection>
<ModalSection v-if="selectedFacility.instructions?.length" title="使用说明">
<ModalSection v-if="selectedFacility.instructions?.length" title="使用说明" icon="fas fa-book-open">
<div class="content-blocks">
<template v-for="(block, bi) in selectedFacility.instructions" :key="bi">
<p v-if="block.type === 'text'">{{ block.content }}</p>
@@ -396,7 +399,7 @@ function generateJson() {
</div>
</ModalSection>
<ModalSection v-if="selectedFacility.notes?.length" title="注意事项">
<ModalSection v-if="selectedFacility.notes?.length" title="注意事项" icon="fas fa-exclamation-triangle">
<div class="content-blocks">
<template v-for="(block, bi) in selectedFacility.notes" :key="bi">
<p v-if="block.type === 'text'">{{ block.content }}</p>
@@ -422,8 +425,8 @@ function generateJson() {
<div class="preview-header">
<div class="preview-title">{{ edTitle || '未命名设施' }}</div>
<div class="modal-badges">
<span :class="['badge', 'large-badge', 'badge-status-' + edStatus]">{{ statusTextMap[edStatus] }}</span>
<span class="badge large-badge badge-type">{{ typeTextMap[edType] }}</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"><i class="fas fa-cube"></i> {{ typeTextMap[edType] }}</span>
</div>
</div>
<div class="preview-body">
@@ -687,6 +690,29 @@ function generateJson() {
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 {
font-size: 14px;
color: var(--bl-text-secondary);

View File

@@ -11,6 +11,7 @@ const projectFilter = ref('all');
const modalOpen = ref(false);
const isMobile = ref(false);
const qrLoaded = ref(false);
onMounted(() => {
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 -->
<div v-if="!isMobile" class="desktop-qr-view">
<div class="qr-placeholder">
<div v-if="!qrLoaded" class="qr-loading">
<i class="fas fa-spinner fa-spin"></i>
<span>加载中</span>
</div>
<img
src="https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=https%3A%2F%2Fqr.alipay.com%2F2cz0344fnaulnbybhp04"
alt="支付宝二维码"
class="qr-img"
:style="{ display: qrLoaded ? 'block' : 'none' }"
@load="qrLoaded = true"
>
</div>
<p class="desktop-qr-hint">推荐使用支付宝扫码</p>
@@ -549,6 +556,25 @@ function setProject(p) {
background: #fff;
border-radius: 16px;
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 {

View File

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