feat: add EditorModal and JsonOutputModal components with styles and helper functions

- Implemented EditorModal for a two-panel editor interface with preview and form slots.
- Created JsonOutputModal to display generated JSON with a copy feature.
- Added useEditorHelpers composable for sortable lists and tag input management.
- Introduced shared styles for editor forms, including custom select and tags input.
This commit is contained in:
zhangyuheng
2026-03-18 15:05:02 +08:00
parent 5c6d389962
commit 14f8ee9018
14 changed files with 2471 additions and 291 deletions

View File

@@ -102,23 +102,52 @@ onBeforeUnmount(() => {
position: relative;
width: min(100%, var(--bl-content-width));
max-height: min(90vh, 980px);
overflow: auto;
overflow-y: auto;
overflow-x: hidden;
border-radius: var(--bl-radius-xl);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(250, 250, 252, 0.98));
box-shadow: var(--bl-shadow-modal);
}
.base-modal__dialog::-webkit-scrollbar {
width: 6px;
}
.base-modal__dialog::-webkit-scrollbar-track {
background: transparent;
margin: 10px 0;
}
.base-modal__dialog::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.15);
border-radius: 10px;
}
.base-modal__dialog::-webkit-scrollbar-thumb:hover {
background: rgba(0, 0, 0, 0.25);
}
.base-modal__close {
position: absolute;
top: 18px;
right: 18px;
width: 38px;
height: 38px;
width: 36px;
height: 36px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.05);
color: var(--bl-text);
background: rgba(255, 255, 255, 0.8);
color: var(--bl-text-secondary);
font-size: 1.3rem;
cursor: pointer;
transition: 0.2s;
z-index: 10;
display: flex;
align-items: center;
justify-content: center;
}
.base-modal__close:hover {
background: #f0f0f0;
color: var(--bl-text);
}
.base-modal__header {

View File

@@ -4,6 +4,10 @@ const props = defineProps({
type: String,
default: '',
},
labelIcon: {
type: String,
default: '',
},
modelValue: {
type: String,
default: 'all',
@@ -19,7 +23,10 @@ const emit = defineEmits(['update:modelValue']);
<template>
<div class="filter-group">
<div v-if="label" class="filter-group__label">{{ label }}</div>
<div v-if="label" class="filter-group__label">
<i v-if="labelIcon" :class="labelIcon"></i>
{{ label }}
</div>
<div class="filter-group__tags" role="group" :aria-label="label || '筛选项'">
<button
v-for="option in options"
@@ -28,7 +35,8 @@ const emit = defineEmits(['update:modelValue']);
:class="['filter-tag', { 'is-active': option.value === modelValue }]"
@click="emit('update:modelValue', option.value)"
>
<span v-if="option.icon" class="filter-tag__icon">{{ option.icon }}</span>
<i v-if="option.iconClass" :class="option.iconClass" class="filter-tag__icon"></i>
<span v-else-if="option.icon" class="filter-tag__icon">{{ option.icon }}</span>
<span>{{ option.label }}</span>
</button>
</div>
@@ -48,6 +56,9 @@ const emit = defineEmits(['update:modelValue']);
font-size: 0.88rem;
font-weight: 700;
color: var(--bl-text-secondary);
display: flex;
align-items: center;
gap: 6px;
}
.filter-group__tags {

View File

@@ -1,7 +1,7 @@
<script setup>
import { RouterLink } from 'vue-router';
import { RouterLink, useRouter } from 'vue-router';
defineProps({
const props = defineProps({
open: {
type: Boolean,
default: false,
@@ -24,133 +24,83 @@ const emit = defineEmits(['close']);
</script>
<template>
<transition name="drawer-fade">
<div v-if="open" class="mobile-drawer-mask" @click="emit('close')">
<aside class="mobile-drawer" @click.stop>
<div class="mobile-drawer__header">
<p>站点导航</p>
<button type="button" class="mobile-drawer__close" aria-label="关闭菜单" @click="emit('close')">
×
</button>
</div>
<nav class="mobile-drawer__links" aria-label="移动端导航">
<template v-for="item in items" :key="item.href">
<a
v-if="item.external"
class="mobile-drawer__link"
:href="item.href"
target="_blank"
rel="noopener noreferrer"
@click="emit('close')"
>
<span>{{ item.label }}</span>
</a>
<RouterLink
v-else
class="mobile-drawer__link"
:to="item.href"
@click="emit('close')"
>
<span>{{ item.label }}</span>
</RouterLink>
</template>
</nav>
<RouterLink class="mobile-drawer__cta" :to="ctaHref" @click="emit('close')">{{ ctaLabel }}</RouterLink>
</aside>
</div>
</transition>
<div :class="['mobile-menu', { active: open }]">
<nav class="mobile-menu-links" aria-label="移动端导航">
<template v-for="item in items" :key="item.href">
<a
v-if="item.external"
:href="item.href"
target="_blank"
rel="noopener noreferrer"
@click="emit('close')"
>{{ item.label }}</a>
<RouterLink
v-else
:to="item.href"
@click="emit('close')"
>{{ item.label }}</RouterLink>
</template>
<RouterLink :to="ctaHref" @click="emit('close')">{{ ctaLabel }}</RouterLink>
</nav>
</div>
</template>
<style scoped>
.drawer-fade-enter-active,
.drawer-fade-leave-active {
transition: opacity 0.25s ease;
}
.drawer-fade-enter-from,
.drawer-fade-leave-to {
opacity: 0;
}
.mobile-drawer-mask {
.mobile-menu {
position: fixed;
inset: 0;
z-index: 1200;
display: flex;
justify-content: flex-end;
background: rgba(15, 23, 42, 0.28);
backdrop-filter: blur(12px);
top: var(--bl-header-height);
left: 0;
width: 100%;
height: 0;
background: rgba(255, 255, 255, 0.98);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
overflow: hidden;
transition: height 0.5s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.3s ease;
opacity: 0;
visibility: hidden;
z-index: 998;
}
.mobile-drawer {
width: min(360px, 100%);
height: 100%;
padding: 24px 20px 28px;
background: rgba(255, 255, 255, 0.96);
box-shadow: -20px 0 60px rgba(15, 23, 42, 0.16);
.mobile-menu.active {
height: calc(100vh - var(--bl-header-height));
opacity: 1;
visibility: visible;
}
.mobile-menu-links {
padding: 24px 40px;
display: flex;
flex-direction: column;
gap: 0;
max-width: 600px;
margin: 0 auto;
}
.mobile-drawer__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
}
.mobile-drawer__header p {
margin: 0;
font-size: 0.9rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--bl-text-tertiary);
}
.mobile-drawer__close {
width: 40px;
height: 40px;
border-radius: 50%;
background: var(--bl-surface-muted);
font-size: 1.4rem;
cursor: pointer;
}
.mobile-drawer__links {
display: grid;
gap: 10px;
}
.mobile-drawer__link {
display: flex;
flex-direction: column;
gap: 2px;
padding: 14px 16px;
border-radius: var(--bl-radius-md);
background: #fff;
text-decoration: none;
border: 1px solid rgba(0, 0, 0, 0.05);
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.03);
}
.mobile-drawer__link span {
.mobile-menu-links a {
display: block;
font-size: 24px;
font-weight: 600;
}
.mobile-drawer__link small {
color: var(--bl-text-secondary);
}
.mobile-drawer__cta {
margin-top: auto;
display: inline-flex;
justify-content: center;
align-items: center;
min-height: 48px;
border-radius: 999px;
background: var(--bl-accent);
color: #fff;
text-decoration: none;
font-weight: 700;
color: var(--bl-text);
padding: 16px 0;
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
opacity: 0;
transform: translateY(-20px);
transition: all 0.4s ease;
}
.mobile-menu.active .mobile-menu-links a {
opacity: 1;
transform: translateY(0);
}
/* Stagger animation */
.mobile-menu.active .mobile-menu-links a:nth-child(1) { transition-delay: 0.1s; }
.mobile-menu.active .mobile-menu-links a:nth-child(2) { transition-delay: 0.15s; }
.mobile-menu.active .mobile-menu-links a:nth-child(3) { transition-delay: 0.2s; }
.mobile-menu.active .mobile-menu-links a:nth-child(4) { transition-delay: 0.25s; }
.mobile-menu.active .mobile-menu-links a:nth-child(5) { transition-delay: 0.3s; }
.mobile-menu.active .mobile-menu-links a:nth-child(6) { transition-delay: 0.35s; }
.mobile-menu.active .mobile-menu-links a:nth-child(7) { transition-delay: 0.4s; }
</style>

View File

@@ -41,12 +41,10 @@ const isActive = (href) => href === props.activePath;
<button
type="button"
class="site-navbar__toggle"
aria-label="打开菜单"
@click="mobileOpen = true"
:aria-label="mobileOpen ? '关闭菜单' : '打开菜单'"
@click="mobileOpen = !mobileOpen"
>
<span></span>
<span></span>
<span></span>
<i :class="mobileOpen ? 'fas fa-times' : 'fas fa-bars'"></i>
</button>
<RouterLink class="site-navbar__logo" to="/">
@@ -108,18 +106,17 @@ const isActive = (href) => href === props.activePath;
height: 40px;
border-radius: 50%;
background: transparent;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 4px;
justify-content: center;
cursor: pointer;
font-size: 20px;
color: var(--bl-text);
padding: 0;
transition: background 0.2s;
}
.site-navbar__toggle span {
width: 16px;
height: 1.5px;
background: var(--bl-text);
border-radius: 999px;
.site-navbar__toggle:hover {
background: rgba(0, 0, 0, 0.05);
}
.site-navbar__logo img {
@@ -182,15 +179,31 @@ const isActive = (href) => href === props.activePath;
@media (max-width: 860px) {
.site-navbar__toggle {
display: inline-flex;
order: 1;
}
.site-navbar__links,
.site-navbar__cta {
.site-navbar__links {
display: none;
}
.site-navbar__logo {
position: absolute;
left: 50%;
transform: translateX(-50%);
order: 2;
margin: 0;
}
.site-navbar__cta {
order: 3;
margin: 0;
padding: 0 12px;
font-size: 11px;
}
.site-navbar__inner {
justify-content: space-between;
padding: 0 15px;
}
}
</style>

View File

@@ -0,0 +1,247 @@
<script setup>
/**
* Two-panel editor modal: left preview + right form.
* Used by Facilities, Towns, Announcements editors.
*/
const props = defineProps({
modelValue: Boolean,
title: { type: String, default: '编辑器' },
icon: { type: String, default: 'fas fa-edit' },
});
const emit = defineEmits(['update:modelValue']);
function close() {
emit('update:modelValue', false);
}
function onOverlayClick(e) {
if (e.target === e.currentTarget) close();
}
</script>
<template>
<Teleport to="body">
<Transition name="editor-fade">
<div v-if="modelValue" class="editor-overlay" @click="onOverlayClick">
<div class="editor-modal-content">
<button type="button" class="close-editor-modal" @click="close">&times;</button>
<div class="editor-modal-header">
<h3><i :class="icon"></i> {{ title }}</h3>
</div>
<div class="editor-layout">
<div class="editor-preview">
<div class="editor-panel-title"><i class="fas fa-eye"></i> 实时预览</div>
<div class="editor-preview-content">
<slot name="preview" />
</div>
</div>
<div class="editor-form">
<div class="editor-panel-title"><i class="fas fa-edit"></i> 编辑内容</div>
<div class="editor-form-scroll">
<slot name="form" />
</div>
</div>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.editor-overlay {
position: fixed;
inset: 0;
z-index: 1000;
background: rgba(0,0,0,0.5);
backdrop-filter: blur(5px);
display: flex;
align-items: center;
justify-content: center;
}
.editor-modal-content {
background: #fff;
margin: 20px auto;
border-radius: 18px;
max-width: 1280px;
width: 95%;
padding: 0;
box-shadow: 0 24px 60px rgba(0,0,0,0.3);
position: relative;
max-height: calc(100vh - 40px);
overflow: hidden;
display: flex;
flex-direction: column;
}
.close-editor-modal {
position: absolute;
top: 16px;
right: 20px;
font-size: 24px;
color: var(--bl-text-secondary);
cursor: pointer;
transition: 0.2s;
z-index: 10;
background: rgba(255,255,255,0.9);
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
border: none;
line-height: 1;
}
.close-editor-modal:hover {
background: #f0f0f0;
color: var(--bl-text);
}
.editor-modal-header {
padding: 20px 28px;
border-bottom: 1px solid rgba(0,0,0,0.08);
background: linear-gradient(to bottom, #fff, #fafafa);
border-radius: 18px 18px 0 0;
flex-shrink: 0;
}
.editor-modal-header h3 {
font-size: 22px;
font-weight: 700;
display: flex;
align-items: center;
gap: 10px;
margin: 0;
}
.editor-layout {
display: flex;
flex: 1;
overflow: hidden;
min-height: 0;
}
.editor-preview {
flex: 0 0 45%;
display: flex;
flex-direction: column;
border-right: 1px solid rgba(0,0,0,0.08);
background: #f5f5f7;
}
.editor-panel-title {
font-size: 12px;
font-weight: 700;
color: var(--bl-text-secondary);
padding: 14px 24px;
border-bottom: 1px solid rgba(0,0,0,0.05);
display: flex;
align-items: center;
gap: 8px;
text-transform: uppercase;
letter-spacing: 0.6px;
flex-shrink: 0;
background: rgba(255,255,255,0.6);
}
.editor-preview-content {
flex: 1;
overflow-y: auto;
padding: 20px;
}
.editor-form {
flex: 0 0 55%;
display: flex;
flex-direction: column;
min-width: 0;
}
.editor-form-scroll {
flex: 1;
overflow-y: auto;
padding: 24px 28px 40px;
}
.editor-preview-content::-webkit-scrollbar,
.editor-form-scroll::-webkit-scrollbar {
width: 5px;
}
.editor-preview-content::-webkit-scrollbar-track,
.editor-form-scroll::-webkit-scrollbar-track {
background: transparent;
}
.editor-preview-content::-webkit-scrollbar-thumb,
.editor-form-scroll::-webkit-scrollbar-thumb {
background: rgba(0,0,0,0.12);
border-radius: 10px;
}
/* Transitions */
.editor-fade-enter-active,
.editor-fade-leave-active {
transition: opacity 0.3s;
}
.editor-fade-enter-active .editor-modal-content,
.editor-fade-leave-active .editor-modal-content {
transition: transform 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);
}
.editor-fade-enter-from,
.editor-fade-leave-to {
opacity: 0;
}
.editor-fade-enter-from .editor-modal-content {
transform: scale(0.9);
}
.editor-fade-leave-to .editor-modal-content {
transform: scale(0.9);
}
@media (max-width: 900px) {
.editor-modal-content {
margin: 0;
width: 100%;
max-width: 100%;
max-height: 100%;
height: 100%;
border-radius: 0;
}
.editor-layout {
flex-direction: column;
}
.editor-preview {
flex: none;
max-height: 35vh;
border-right: none;
border-bottom: 1px solid rgba(0,0,0,0.08);
}
.editor-form {
flex: 1;
min-height: 0;
}
.close-editor-modal {
top: 12px;
right: 14px;
}
}
@media (max-width: 768px) {
.editor-modal-header {
padding: 16px 20px;
}
.editor-form-scroll {
padding: 20px;
}
}
</style>

View File

@@ -54,6 +54,7 @@ const emit = defineEmits(['update:searchValue', 'change-filter', 'action']);
v-for="filter in filters"
:key="filter.key"
:label="filter.label"
:label-icon="filter.labelIcon"
:options="filter.options"
:model-value="filter.modelValue"
@update:model-value="emit('change-filter', { key: filter.key, value: $event })"

View File

@@ -0,0 +1,180 @@
<script setup>
/**
* JSON output modal — shows generated JSON and provides copy button.
*/
import { ref } from 'vue';
const props = defineProps({
modelValue: Boolean,
jsonText: { type: String, default: '' },
});
const emit = defineEmits(['update:modelValue']);
const copied = ref(false);
function close() {
emit('update:modelValue', false);
}
function onOverlayClick(e) {
if (e.target === e.currentTarget) close();
}
function copyJson() {
navigator.clipboard.writeText(props.jsonText).then(() => {
copied.value = true;
setTimeout(() => { copied.value = false; }, 2000);
});
}
</script>
<template>
<Teleport to="body">
<Transition name="json-fade">
<div v-if="modelValue" class="json-overlay" @click="onOverlayClick">
<div class="json-output-content">
<button type="button" class="close-json-modal" @click="close">&times;</button>
<h3><i class="fas fa-code"></i> 生成完成</h3>
<p class="json-output-hint">请复制以下 JSON 内容发送给服主以更新到网站上</p>
<textarea class="json-output-textarea" readonly :value="jsonText"></textarea>
<button type="button" class="btn-copy-json" :style="copied ? { background: '#34c759' } : {}" @click="copyJson">
<template v-if="copied"><i class="fas fa-check"></i> 已复制</template>
<template v-else><i class="fas fa-copy"></i> 复制到剪贴板</template>
</button>
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.json-overlay {
position: fixed;
inset: 0;
z-index: 1100;
background: rgba(0,0,0,0.5);
backdrop-filter: blur(5px);
display: flex;
align-items: center;
justify-content: center;
}
.json-output-content {
background: #fff;
margin: 60px auto;
border-radius: 24px;
max-width: 640px;
width: 90%;
padding: 36px;
box-shadow: 0 24px 60px rgba(0,0,0,0.3);
position: relative;
}
.close-json-modal {
position: absolute;
top: 16px;
right: 20px;
font-size: 24px;
color: var(--bl-text-secondary);
cursor: pointer;
transition: 0.2s;
z-index: 10;
background: rgba(255,255,255,0.9);
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
border: none;
line-height: 1;
}
.close-json-modal:hover {
background: #f0f0f0;
color: var(--bl-text);
}
.json-output-content h3 {
font-size: 20px;
font-weight: 700;
margin: 0 0 8px;
display: flex;
align-items: center;
gap: 10px;
}
.json-output-hint {
font-size: 14px;
color: var(--bl-text-secondary);
margin: 0 0 20px;
line-height: 1.5;
}
.json-output-textarea {
width: 100%;
height: 300px;
padding: 16px;
border: 1.5px solid rgba(0,0,0,0.1);
border-radius: 12px;
font-family: 'Courier New', Courier, monospace;
font-size: 12px;
background: #f5f5f7;
color: var(--bl-text);
resize: vertical;
margin-bottom: 16px;
box-sizing: border-box;
}
.json-output-textarea:focus {
outline: none;
border-color: var(--bl-accent);
}
.btn-copy-json {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 24px;
background: var(--bl-accent);
color: #fff;
border: none;
border-radius: 12px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: 0.2s;
width: 100%;
justify-content: center;
font-family: inherit;
}
.btn-copy-json:hover {
opacity: 0.9;
}
.json-fade-enter-active,
.json-fade-leave-active {
transition: opacity 0.3s;
}
.json-fade-enter-from,
.json-fade-leave-to {
opacity: 0;
}
@media (max-width: 768px) {
.json-output-content {
margin: 0;
width: 100%;
height: 100%;
max-height: 100%;
border-radius: 0;
display: flex;
flex-direction: column;
}
.json-output-textarea {
flex: 1;
}
}
</style>

View File

@@ -0,0 +1,63 @@
import { ref, nextTick } from 'vue';
/**
* Composable for sortable content block lists (text/image/video)
* with drag-and-drop reordering.
*/
export function useSortableList(initialItems = []) {
const items = ref(initialItems.map(i => ({ ...i })));
function reset(newItems = []) {
items.value = newItems.map(i => ({ ...i }));
}
function addItem(type) {
items.value.push({ type, content: '' });
}
function removeItem(idx) {
items.value.splice(idx, 1);
}
function updateContent(idx, content) {
items.value[idx].content = content;
}
function moveItem(fromIdx, toIdx) {
if (fromIdx === toIdx) return;
const [moved] = items.value.splice(fromIdx, 1);
items.value.splice(toIdx, 0, moved);
}
function getCleanItems() {
return items.value
.filter(i => i.content.trim() !== '')
.map(i => ({ type: i.type, content: i.content.trim() }));
}
return { items, reset, addItem, removeItem, updateContent, moveItem, getCleanItems };
}
/**
* Composable for tag-style input (e.g. contributor names).
*/
export function useTagsInput(initialTags = []) {
const tags = ref([...initialTags]);
function reset(newTags = []) {
tags.value = [...newTags];
}
function addTag(value) {
const v = value.trim();
if (v && !tags.value.includes(v)) {
tags.value.push(v);
}
}
function removeTag(idx) {
tags.value.splice(idx, 1);
}
return { tags, reset, addTag, removeTag };
}

View File

@@ -2,8 +2,10 @@
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';
import EditorModal from '../components/shared/EditorModal.vue';
import JsonOutputModal from '../components/shared/JsonOutputModal.vue';
import { useSortableList } from '../composables/useEditorHelpers.js';
const route = useRoute();
@@ -13,6 +15,18 @@ const categoryFilter = ref('all');
const expandedId = ref(null);
const editMode = ref(false);
const sharedId = ref(null);
const editorOpen = ref(false);
const jsonOutputOpen = ref(false);
const jsonOutputText = ref('');
// Editor form state
const edTitle = ref('');
const edIntro = ref('');
const edTime = ref('');
const edCategory = ref('activity');
const openSelects = ref({});
const content = useSortableList();
const dragState = ref({ listName: null, fromIdx: null });
// Secret "edit" keyboard shortcut
let secretBuffer = '';
@@ -71,13 +85,14 @@ function generateAnchorId(item) {
const categoryOptions = [
{ value: 'all', label: '全部' },
{ value: 'activity', label: '活动' },
{ value: 'maintenance', label: '维护' },
{ value: 'other', label: '其他' },
{ value: 'activity', label: '活动', iconClass: 'fas fa-calendar-check' },
{ value: 'maintenance', label: '维护', iconClass: 'fas fa-wrench' },
{ value: 'other', label: '其他', iconClass: 'fas fa-info-circle' },
];
const categoryLabelMap = { activity: '活动', maintenance: '维护', other: '其他' };
const categoryToneMap = { activity: 'success', maintenance: 'warning', other: 'purple' };
const categoryIconMap = { activity: 'fas fa-calendar-check', maintenance: 'fas fa-wrench', other: 'fas fa-info-circle' };
const filtered = computed(() => {
return announcements.value.filter(item => {
@@ -111,6 +126,91 @@ function parseBV(input) {
function onFilterChange({ key, value }) {
if (key === 'category') categoryFilter.value = value;
}
// ========== Editor ==========
const categorySelectOptions = [
{ value: 'activity', label: '活动' },
{ value: 'maintenance', label: '维护' },
{ value: 'other', label: '其他' },
];
function getSelectLabel(options, value) {
return options.find(o => o.value === value)?.label || value;
}
function toggleSelect(name) {
openSelects.value[name] = !openSelects.value[name];
}
function selectOption(name, value) {
if (name === 'category') edCategory.value = value;
openSelects.value[name] = false;
}
function closeAllSelects() {
openSelects.value = {};
}
function openEditor(item) {
edTitle.value = item ? item.title : '';
edIntro.value = item ? item.intro : '';
edTime.value = item ? item.time : new Date().toISOString().slice(0, 10);
edCategory.value = item?.category || 'activity';
content.reset(item?.content || []);
openSelects.value = {};
editorOpen.value = true;
}
function onDragStart(listName, idx, e) {
dragState.value = { listName, fromIdx: idx };
e.target.closest('.sortable-item').classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', '');
}
function onDragOver(e) {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
}
function onDragEnter(listName, idx, e) {
if (dragState.value.listName === listName) {
e.target.closest('.sortable-item')?.classList.add('drag-over');
}
}
function onDragLeave(e) {
e.target.closest('.sortable-item')?.classList.remove('drag-over');
}
function onDrop(listName, toIdx, e) {
e.preventDefault();
e.target.closest('.sortable-item')?.classList.remove('drag-over');
if (dragState.value.listName !== listName) return;
content.moveItem(dragState.value.fromIdx, toIdx);
}
function onDragEnd(e) {
document.querySelectorAll('.sortable-item').forEach(el => el.classList.remove('dragging', 'drag-over'));
dragState.value = { listName: null, fromIdx: null };
}
function generateJson() {
if (!edTitle.value.trim()) {
alert('请填写公告标题');
return;
}
const obj = {
title: edTitle.value.trim(),
intro: edIntro.value.trim(),
time: edTime.value,
category: edCategory.value,
content: content.getCleanItems().map(i => i.type === 'video' ? { type: 'video', content: parseBV(i.content) || i.content } : i),
};
jsonOutputText.value = JSON.stringify(obj, null, 4);
jsonOutputOpen.value = true;
}
</script>
<template>
@@ -130,10 +230,12 @@ function onFilterChange({ key, value }) {
:search-value="searchQuery"
search-placeholder="搜索标题或简介..."
:filters="[
{ key: 'category', label: '分类', options: categoryOptions, modelValue: categoryFilter },
{ key: 'category', label: '分类', labelIcon: 'fas fa-tag', options: categoryOptions, modelValue: categoryFilter },
]"
:action-label="editMode ? '新增公告' : ''"
@update:search-value="searchQuery = $event"
@change-filter="onFilterChange"
@action="openEditor(null)"
/>
<!-- Timeline -->
@@ -149,14 +251,15 @@ function onFilterChange({ key, value }) {
<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'">
<span :class="['category-badge', 'badge-' + item.category]">
<i :class="categoryIconMap[item.category]"></i>
{{ categoryLabelMap[item.category] || item.category }}
</BaseBadge>
</span>
<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="card-summary-time"><i class="far fa-clock"></i> {{ item.time }}</span>
<span class="expand-icon"></span>
</button>
@@ -182,7 +285,10 @@ function onFilterChange({ key, value }) {
:class="['btn-share', { shared: sharedId === generateAnchorId(item) }]"
@click="shareItem(item, $event)"
>
{{ sharedId === generateAnchorId(item) ? '✓ 已复制链接' : '🔗 分享' }}
<template v-if="sharedId === generateAnchorId(item)">✓ 已复制链接</template><template v-else><i class="fas fa-share-alt"></i> 分享</template>
</button>
<button v-if="editMode" type="button" class="btn-edit" @click.stop="openEditor(item)">
<i class="fas fa-pen"></i> 编辑
</button>
</div>
</div>
@@ -192,10 +298,99 @@ function onFilterChange({ key, value }) {
<!-- Empty -->
<EmptyState v-else title="暂无公告" description="当前没有匹配的公告内容。" />
<!-- Editor Modal -->
<EditorModal v-model="editorOpen" title="公告编辑器" icon="fas fa-bullhorn">
<template #preview>
<div class="preview-card">
<div class="preview-header">
<div class="preview-title">{{ edTitle || '未命名公告' }}</div>
<div class="preview-meta-row">
<span :class="['category-badge', 'badge-' + edCategory]">
<i :class="categoryIconMap[edCategory]"></i>
{{ categoryLabelMap[edCategory] }}
</span>
<span class="preview-time"><i class="far fa-clock"></i> {{ edTime || '未设置时间' }}</span>
</div>
</div>
<div class="preview-body">
<p class="preview-intro">{{ edIntro || '暂无简介' }}</p>
<div class="content-blocks" v-if="content.items.value.length">
<template v-for="(block, bi) in content.items.value" :key="bi">
<p v-if="block.type === 'text'">{{ block.content || '空文字' }}</p>
<img v-else-if="block.type === 'image' && block.content" :src="block.content" loading="lazy" alt="">
<p v-else-if="block.type === 'image'" class="preview-text-secondary">空图片</p>
<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>
<p v-else-if="block.type === 'video'" class="preview-text-secondary">请输入有效的 BV 号或 bilibili 视频地址</p>
</template>
</div>
<p v-else></p>
</div>
</div>
</template>
<template #form>
<div @click="closeAllSelects">
<div class="form-group">
<label>公告标题</label>
<input type="text" v-model="edTitle" placeholder="输入公告标题...">
</div>
<div class="form-group">
<label>简介</label>
<textarea v-model="edIntro" placeholder="输入简介..." rows="2"></textarea>
</div>
<div class="form-row">
<div class="form-group">
<label>时间</label>
<input type="date" v-model="edTime">
</div>
<div class="form-group">
<label>类别</label>
<div :class="['custom-select', { open: openSelects.category }]" @click.stop>
<div class="custom-select-trigger" @click="toggleSelect('category')">
<span>{{ getSelectLabel(categorySelectOptions, edCategory) }}</span>
<i class="fas fa-chevron-down"></i>
</div>
<div class="custom-select-options">
<div v-for="opt in categorySelectOptions" :key="opt.value" :class="['custom-option', { selected: edCategory === opt.value }]" @click="selectOption('category', opt.value)">{{ opt.label }}</div>
</div>
</div>
</div>
</div>
<div class="form-group">
<label>正文内容</label>
<div class="sortable-list">
<div v-for="(item, idx) in content.items.value" :key="idx" class="sortable-item" draggable="true" @dragstart="onDragStart('content', idx, $event)" @dragover="onDragOver" @dragenter="onDragEnter('content', idx, $event)" @dragleave="onDragLeave" @drop="onDrop('content', idx, $event)" @dragend="onDragEnd">
<span class="drag-handle"><i class="fas fa-grip-vertical"></i></span>
<span :class="['item-type-badge', 'badge-' + item.type]">{{ item.type === 'text' ? '文字' : item.type === 'image' ? '图片' : '视频' }}</span>
<textarea v-if="item.type === 'text'" class="item-content" rows="2" placeholder="输入文字内容..." :value="item.content" @input="content.updateContent(idx, $event.target.value)"></textarea>
<input v-else-if="item.type === 'image'" type="text" class="item-content" placeholder="输入图片URL..." :value="item.content" @input="content.updateContent(idx, $event.target.value)">
<input v-else type="text" class="item-content" placeholder="BV1xxxxxxxxxx 或 bilibili 视频地址" :value="item.content" @input="content.updateContent(idx, $event.target.value)">
<button type="button" class="remove-item-btn" @click="content.removeItem(idx)"><i class="fas fa-trash-alt"></i></button>
</div>
</div>
<div class="add-item-row">
<button type="button" class="add-item-btn" @click="content.addItem('text')"><i class="fas fa-plus"></i> 添加文字</button>
<button type="button" class="add-item-btn" @click="content.addItem('image')"><i class="fas fa-image"></i> 添加图片</button>
<button type="button" class="add-item-btn" @click="content.addItem('video')"><i class="fas fa-video"></i> 添加视频</button>
</div>
</div>
<div class="editor-actions">
<button type="button" class="btn-generate-json" @click="generateJson"><i class="fas fa-save"></i> 生成 JSON</button>
</div>
</div>
</template>
</EditorModal>
<JsonOutputModal v-model="jsonOutputOpen" :json-text="jsonOutputText" />
</main>
</template>
<style scoped>
@import '../styles/editor-form.css';
.announcements-hero {
height: 35vh;
min-height: 300px;
@@ -321,6 +516,8 @@ function onFilterChange({ key, value }) {
align-items: center;
gap: 16px;
background: transparent;
border: none;
font-family: inherit;
cursor: pointer;
text-align: left;
}
@@ -377,6 +574,37 @@ function onFilterChange({ key, value }) {
color: var(--bl-text-secondary);
white-space: nowrap;
flex-shrink: 0;
display: flex;
align-items: center;
gap: 5px;
}
/* Category Badge */
.category-badge {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 4px 12px;
border-radius: 20px;
font-size: 12px;
font-weight: 600;
white-space: nowrap;
flex-shrink: 0;
}
.badge-activity {
background: #e8fceb;
color: #15803d;
}
.badge-maintenance {
background: #fff8d6;
color: #b45309;
}
.badge-other {
background: #f3e8ff;
color: #7c3aed;
}
.expand-icon {
@@ -476,6 +704,32 @@ function onFilterChange({ key, value }) {
background: rgba(0, 113, 227, 0.04);
}
.btn-share.shared {
color: #15803d;
border-color: #34c759;
background: #e8fceb;
}
.btn-edit {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 16px;
background: transparent;
color: var(--bl-accent);
border: 1.5px solid var(--bl-accent);
border-radius: 18px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: var(--bl-transition);
}
.btn-edit:hover {
background: var(--bl-accent);
color: #fff;
}
@media (max-width: 768px) {
.hero-title {
font-size: 36px;
@@ -504,4 +758,45 @@ function onFilterChange({ key, value }) {
padding: 20px;
}
}
/* Editor-specific */
.preview-meta-row {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
margin-top: 8px;
}
.preview-time {
font-size: 13px;
color: var(--bl-text-secondary);
display: flex;
align-items: center;
gap: 5px;
}
.content-blocks {
background: #f9f9fa;
padding: 20px;
border-radius: 12px;
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 16px;
border: 1px solid rgba(0,0,0,0.05);
}
</style>

View File

@@ -6,6 +6,9 @@ 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';
import EditorModal from '../components/shared/EditorModal.vue';
import JsonOutputModal from '../components/shared/JsonOutputModal.vue';
import { useSortableList, useTagsInput } from '../composables/useEditorHelpers.js';
const route = useRoute();
@@ -16,19 +19,28 @@ const dimensionFilter = ref('all');
const modalOpen = ref(false);
const selectedFacility = ref(null);
const sharedId = ref(null);
const editMode = ref(false);
const editorOpen = ref(false);
const jsonOutputOpen = ref(false);
const jsonOutputText = ref('');
// 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 = ''; }
}
// Editor form state
const edTitle = ref('');
const edIntro = ref('');
const edType = ref('resource');
const edStatus = ref('online');
const edDimension = ref('overworld');
const edX = ref('');
const edY = ref('');
const edZ = ref('');
const openSelects = ref({});
const contributors = useTagsInput();
const instructions = useSortableList();
const notes = useSortableList();
// Drag state for sortable lists
const dragState = ref({ listName: null, fromIdx: null });
onMounted(() => {
document.addEventListener('keydown', onSecretKey);
fetch('/data/facilities.json')
.then(r => r.json())
.then(data => {
@@ -56,16 +68,16 @@ function generateId(item) {
const typeOptions = [
{ value: 'all', label: '全部' },
{ value: 'resource', label: '资源' },
{ value: 'xp', label: '经验' },
{ value: 'infrastructure', label: '基建' },
{ value: 'resource', label: '资源', iconClass: 'fas fa-cube' },
{ value: 'xp', label: '经验', iconClass: 'fas fa-star' },
{ value: 'infrastructure', label: '基建', iconClass: 'fas fa-road' },
];
const dimensionOptions = [
{ value: 'all', label: '全部' },
{ value: 'overworld', label: '主世界' },
{ value: 'nether', label: '下界' },
{ value: 'end', label: '末地' },
{ value: 'overworld', label: '主世界', iconClass: 'fas fa-sun' },
{ value: 'nether', label: '下界', iconClass: 'fas fa-fire' },
{ value: 'end', label: '末地', iconClass: 'fas fa-dragon' },
];
const typeTextMap = { resource: '资源', xp: '经验', infrastructure: '基建' };
@@ -121,6 +133,141 @@ function onFilterChange({ key, value }) {
if (key === 'type') typeFilter.value = value;
if (key === 'dimension') dimensionFilter.value = value;
}
// ========== Editor ==========
const typeSelectOptions = [
{ value: 'resource', label: '资源类' },
{ value: 'xp', label: '经验类' },
{ value: 'infrastructure', label: '基础设施' },
];
const statusSelectOptions = [
{ value: 'online', label: '正常运行' },
{ value: 'maintenance', label: '维护中' },
{ value: 'offline', label: '暂时失效' },
];
const dimensionSelectOptions = [
{ value: 'overworld', label: '主世界' },
{ value: 'nether', label: '下界' },
{ value: 'end', label: '末地' },
];
function getSelectLabel(options, value) {
return options.find(o => o.value === value)?.label || value;
}
function toggleSelect(name) {
openSelects.value[name] = !openSelects.value[name];
}
function selectOption(name, value) {
if (name === 'type') edType.value = value;
else if (name === 'status') edStatus.value = value;
else if (name === 'dimension') edDimension.value = value;
openSelects.value[name] = false;
}
function closeAllSelects() {
openSelects.value = {};
}
function openEditor(item) {
edTitle.value = item ? item.title : '';
edIntro.value = item ? item.intro : '';
edType.value = item ? item.type : 'resource';
edStatus.value = item ? item.status : 'online';
edDimension.value = item ? item.dimension : 'overworld';
edX.value = item?.coordinates ? String(item.coordinates.x) : '';
edY.value = item?.coordinates ? String(item.coordinates.y) : '';
edZ.value = item?.coordinates ? String(item.coordinates.z) : '';
contributors.reset(item?.contributors || []);
instructions.reset(item?.instructions || []);
notes.reset(item?.notes || []);
openSelects.value = {};
editorOpen.value = true;
}
function openEditorFromModal(item) {
modalOpen.value = false;
selectedFacility.value = null;
nextTick(() => openEditor(item));
}
function onContributorKeydown(e) {
if (e.isComposing) return;
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
commitContributorInput(e.target);
}
}
function commitContributorInput(input) {
const val = input.value.trim();
if (val) {
contributors.addTag(val);
input.value = '';
}
}
// Drag-and-drop for sortable lists
function onDragStart(listName, idx, e) {
dragState.value = { listName, fromIdx: idx };
e.target.closest('.sortable-item').classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', '');
}
function onDragOver(e) {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
}
function onDragEnter(listName, idx, e) {
if (dragState.value.listName === listName) {
e.target.closest('.sortable-item')?.classList.add('drag-over');
}
}
function onDragLeave(e) {
e.target.closest('.sortable-item')?.classList.remove('drag-over');
}
function onDrop(listName, toIdx, e) {
e.preventDefault();
e.target.closest('.sortable-item')?.classList.remove('drag-over');
if (dragState.value.listName !== listName) return;
const list = listName === 'instructions' ? instructions : notes;
list.moveItem(dragState.value.fromIdx, toIdx);
}
function onDragEnd(e) {
document.querySelectorAll('.sortable-item').forEach(el => el.classList.remove('dragging', 'drag-over'));
dragState.value = { listName: null, fromIdx: null };
}
function generateJson() {
if (!edTitle.value.trim()) {
alert('请填写设施名称');
return;
}
const obj = {
title: edTitle.value.trim(),
intro: edIntro.value.trim(),
type: edType.value,
dimension: edDimension.value,
status: edStatus.value,
coordinates: {
x: parseInt(edX.value) || 0,
y: parseInt(edY.value) || 64,
z: parseInt(edZ.value) || 0,
},
contributors: [...contributors.tags.value],
instructions: instructions.getCleanItems().map(i => i.type === 'video' ? { type: 'video', content: parseBV(i.content) || i.content } : i),
notes: notes.getCleanItems().map(n => n.type === 'video' ? { type: 'video', content: parseBV(n.content) || n.content } : n),
};
jsonOutputText.value = JSON.stringify(obj, null, 4);
jsonOutputOpen.value = true;
}
</script>
<template>
@@ -140,11 +287,13 @@ function onFilterChange({ key, value }) {
:search-value="searchQuery"
search-placeholder="搜索设施名称或简介..."
:filters="[
{ key: 'type', label: '类型', options: typeOptions, modelValue: typeFilter },
{ key: 'dimension', label: '维度', options: dimensionOptions, modelValue: dimensionFilter },
{ key: 'type', label: '类型', labelIcon: 'fas fa-layer-group', options: typeOptions, modelValue: typeFilter },
{ key: 'dimension', label: '维度', labelIcon: 'fas fa-globe', options: dimensionOptions, modelValue: dimensionFilter },
]"
action-label="新增设施"
@update:search-value="searchQuery = $event"
@change-filter="onFilterChange"
@action="openEditor(null)"
/>
<!-- Grid -->
@@ -179,12 +328,12 @@ function onFilterChange({ key, value }) {
<p class="modal-intro">{{ selectedFacility.intro }}</p>
<div class="modal-badges-row">
<div class="modal-badges">
<BaseBadge :tone="statusToneMap[selectedFacility.status]">
<span :class="['badge', 'large-badge', 'badge-status-' + selectedFacility.status]">
{{ statusTextMap[selectedFacility.status] }}
</BaseBadge>
<BaseBadge tone="accent">
</span>
<span class="badge large-badge badge-type">
{{ typeTextMap[selectedFacility.type] }}
</BaseBadge>
</span>
</div>
<div class="modal-actions">
<button
@@ -192,7 +341,10 @@ function onFilterChange({ key, value }) {
:class="['btn-share', { shared: sharedId === generateId(selectedFacility) }]"
@click="shareItem(selectedFacility)"
>
{{ sharedId === generateId(selectedFacility) ? '✓ 已复制' : '🔗 分享' }}
<template v-if="sharedId === generateId(selectedFacility)">✓ 已复制</template><template v-else><i class="fas fa-share-alt"></i> 分享</template>
</button>
<button type="button" class="btn-edit" @click="openEditorFromModal(selectedFacility)">
<i class="fas fa-pen"></i> 编辑
</button>
</div>
</div>
@@ -213,7 +365,7 @@ function onFilterChange({ key, value }) {
rel="noopener"
class="map-link"
>
🗺 在地图中查看
<i class="fas fa-map-marked-alt"></i> 在地图中查看
</a>
</p>
</ModalSection>
@@ -262,10 +414,192 @@ function onFilterChange({ key, value }) {
</ModalSection>
</template>
</BaseModal>
<!-- Editor Modal -->
<EditorModal v-model="editorOpen" title="设施编辑器" icon="fas fa-tools">
<template #preview>
<div class="preview-card">
<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>
</div>
</div>
<div class="preview-body">
<p class="preview-intro">{{ edIntro || '暂无简介' }}</p>
<div class="preview-section">
<div class="preview-section-title"><i class="fas fa-map-marker-alt"></i> 位置信息</div>
<p>{{ dimensionTextMap[edDimension] }}: X: {{ edX || '0' }}, Y: {{ edY || '64' }}, Z: {{ edZ || '0' }}</p>
</div>
<div class="preview-section">
<div class="preview-section-title"><i class="fas fa-users-cog"></i> 贡献/维护人员</div>
<div v-if="contributors.tags.value.length" class="contributors-list">
<span v-for="name in contributors.tags.value" :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="preview-text-secondary">暂无记录</span>
</div>
<div class="preview-section">
<div class="preview-section-title"><i class="fas fa-book-open"></i> 使用说明</div>
<div class="content-blocks" v-if="instructions.items.value.length">
<template v-for="(block, bi) in instructions.items.value" :key="bi">
<p v-if="block.type === 'text'">{{ block.content || '空文字' }}</p>
<img v-else-if="block.type === 'image' && block.content" :src="block.content" loading="lazy" alt="">
<p v-else-if="block.type === 'image'" class="preview-text-secondary">空图片</p>
<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>
<p v-else-if="block.type === 'video'" class="preview-text-secondary">请输入有效的 BV 号或 bilibili 视频地址</p>
</template>
</div>
<p v-else></p>
</div>
<div class="preview-section">
<div class="preview-section-title"><i class="fas fa-exclamation-triangle"></i> 注意事项</div>
<div class="content-blocks" v-if="notes.items.value.length">
<template v-for="(block, bi) in notes.items.value" :key="bi">
<p v-if="block.type === 'text'">{{ block.content || '空文字' }}</p>
<img v-else-if="block.type === 'image' && block.content" :src="block.content" loading="lazy" alt="">
<p v-else-if="block.type === 'image'" class="preview-text-secondary">空图片</p>
<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>
<p v-else-if="block.type === 'video'" class="preview-text-secondary">请输入有效的 BV 号或 bilibili 视频地址</p>
</template>
</div>
<p v-else></p>
</div>
</div>
</div>
</template>
<template #form>
<div @click="closeAllSelects">
<div class="form-group">
<label>设施名称</label>
<input type="text" v-model="edTitle" placeholder="输入设施名称...">
</div>
<div class="form-group">
<label>设施简介</label>
<textarea v-model="edIntro" placeholder="输入设施简介..." rows="3"></textarea>
</div>
<div class="form-row">
<div class="form-group">
<label>类型</label>
<div :class="['custom-select', { open: openSelects.type }]" @click.stop>
<div class="custom-select-trigger" @click="toggleSelect('type')">
<span>{{ getSelectLabel(typeSelectOptions, edType) }}</span>
<i class="fas fa-chevron-down"></i>
</div>
<div class="custom-select-options">
<div v-for="opt in typeSelectOptions" :key="opt.value" :class="['custom-option', { selected: edType === opt.value }]" @click="selectOption('type', opt.value)">{{ opt.label }}</div>
</div>
</div>
</div>
<div class="form-group">
<label>状态</label>
<div :class="['custom-select', { open: openSelects.status }]" @click.stop>
<div class="custom-select-trigger" @click="toggleSelect('status')">
<span>{{ getSelectLabel(statusSelectOptions, edStatus) }}</span>
<i class="fas fa-chevron-down"></i>
</div>
<div class="custom-select-options">
<div v-for="opt in statusSelectOptions" :key="opt.value" :class="['custom-option', { selected: edStatus === opt.value }]" @click="selectOption('status', opt.value)">{{ opt.label }}</div>
</div>
</div>
</div>
<div class="form-group">
<label>维度</label>
<div :class="['custom-select', { open: openSelects.dimension }]" @click.stop>
<div class="custom-select-trigger" @click="toggleSelect('dimension')">
<span>{{ getSelectLabel(dimensionSelectOptions, edDimension) }}</span>
<i class="fas fa-chevron-down"></i>
</div>
<div class="custom-select-options">
<div v-for="opt in dimensionSelectOptions" :key="opt.value" :class="['custom-option', { selected: edDimension === opt.value }]" @click="selectOption('dimension', opt.value)">{{ opt.label }}</div>
</div>
</div>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>X 坐标</label>
<input type="number" v-model="edX" placeholder="0">
</div>
<div class="form-group">
<label>Y 坐标</label>
<input type="number" v-model="edY" placeholder="64">
</div>
<div class="form-group">
<label>Z 坐标</label>
<input type="number" v-model="edZ" placeholder="0">
</div>
</div>
<div class="form-group">
<label>贡献/维护人员</label>
<div class="tags-input-wrapper" @click="$refs.contributorInput.focus()">
<div class="tags-list">
<span v-for="(tag, ti) in contributors.tags.value" :key="ti" class="editor-tag">
{{ tag }}
<span class="editor-tag-remove" @click="contributors.removeTag(ti)"><i class="fas fa-times"></i></span>
</span>
</div>
<input ref="contributorInput" type="text" placeholder="输入名称后按回车或空格添加..." @keydown="onContributorKeydown" @blur="commitContributorInput($event.target)">
</div>
</div>
<div class="form-group">
<label>使用说明</label>
<div class="sortable-list">
<div v-for="(item, idx) in instructions.items.value" :key="idx" class="sortable-item" draggable="true" @dragstart="onDragStart('instructions', idx, $event)" @dragover="onDragOver" @dragenter="onDragEnter('instructions', idx, $event)" @dragleave="onDragLeave" @drop="onDrop('instructions', idx, $event)" @dragend="onDragEnd">
<span class="drag-handle"><i class="fas fa-grip-vertical"></i></span>
<span :class="['item-type-badge', 'badge-' + item.type]">{{ item.type === 'text' ? '文字' : item.type === 'image' ? '图片' : '视频' }}</span>
<textarea v-if="item.type === 'text'" class="item-content" rows="2" placeholder="输入文字内容..." :value="item.content" @input="instructions.updateContent(idx, $event.target.value)"></textarea>
<input v-else-if="item.type === 'image'" type="text" class="item-content" placeholder="输入图片URL..." :value="item.content" @input="instructions.updateContent(idx, $event.target.value)">
<input v-else type="text" class="item-content" placeholder="BV1xxxxxxxxxx 或 bilibili 视频地址" :value="item.content" @input="instructions.updateContent(idx, $event.target.value)">
<button type="button" class="remove-item-btn" @click="instructions.removeItem(idx)"><i class="fas fa-trash-alt"></i></button>
</div>
</div>
<div class="add-item-row">
<button type="button" class="add-item-btn" @click="instructions.addItem('text')"><i class="fas fa-plus"></i> 添加文字</button>
<button type="button" class="add-item-btn" @click="instructions.addItem('image')"><i class="fas fa-image"></i> 添加图片</button>
<button type="button" class="add-item-btn" @click="instructions.addItem('video')"><i class="fas fa-video"></i> 添加视频</button>
</div>
</div>
<div class="form-group">
<label>注意事项</label>
<div class="sortable-list">
<div v-for="(item, idx) in notes.items.value" :key="idx" class="sortable-item" draggable="true" @dragstart="onDragStart('notes', idx, $event)" @dragover="onDragOver" @dragenter="onDragEnter('notes', idx, $event)" @dragleave="onDragLeave" @drop="onDrop('notes', idx, $event)" @dragend="onDragEnd">
<span class="drag-handle"><i class="fas fa-grip-vertical"></i></span>
<span :class="['item-type-badge', 'badge-' + item.type]">{{ item.type === 'text' ? '文字' : item.type === 'image' ? '图片' : '视频' }}</span>
<textarea v-if="item.type === 'text'" class="item-content" rows="2" placeholder="输入文字内容..." :value="item.content" @input="notes.updateContent(idx, $event.target.value)"></textarea>
<input v-else-if="item.type === 'image'" type="text" class="item-content" placeholder="输入图片URL..." :value="item.content" @input="notes.updateContent(idx, $event.target.value)">
<input v-else type="text" class="item-content" placeholder="BV1xxxxxxxxxx 或 bilibili 视频地址" :value="item.content" @input="notes.updateContent(idx, $event.target.value)">
<button type="button" class="remove-item-btn" @click="notes.removeItem(idx)"><i class="fas fa-trash-alt"></i></button>
</div>
</div>
<div class="add-item-row">
<button type="button" class="add-item-btn" @click="notes.addItem('text')"><i class="fas fa-plus"></i> 添加文字</button>
<button type="button" class="add-item-btn" @click="notes.addItem('image')"><i class="fas fa-image"></i> 添加图片</button>
<button type="button" class="add-item-btn" @click="notes.addItem('video')"><i class="fas fa-video"></i> 添加视频</button>
</div>
</div>
<div class="editor-actions">
<button type="button" class="btn-generate-json" @click="generateJson"><i class="fas fa-save"></i> 生成 JSON</button>
</div>
</div>
</template>
</EditorModal>
<JsonOutputModal v-model="jsonOutputOpen" :json-text="jsonOutputText" />
</main>
</template>
<style scoped>
@import '../styles/editor-form.css';
.facilities-hero {
height: 35vh;
min-height: 300px;
@@ -407,6 +741,37 @@ function onFilterChange({ key, value }) {
flex-wrap: wrap;
}
/* Modal badges */
.badge.large-badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
border-radius: 8px;
font-size: 13px;
font-weight: 600;
}
.badge-status-online {
background: #e8fceb;
color: #15803d;
}
.badge-status-maintenance {
background: #fff8d6;
color: #b45309;
}
.badge-status-offline {
background: #feebeb;
color: #b91c1c;
}
.badge-type {
background: #e0f2fe;
color: #0369a1;
}
.modal-actions {
display: flex;
gap: 8px;
@@ -439,6 +804,26 @@ function onFilterChange({ key, value }) {
background: #e8fceb;
}
.btn-edit {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 16px;
background: transparent;
color: var(--bl-accent);
border: 1.5px solid var(--bl-accent);
border-radius: 18px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: var(--bl-transition);
}
.btn-edit:hover {
background: var(--bl-accent);
color: #fff;
}
.map-link {
display: inline-flex;
align-items: center;

View File

@@ -123,8 +123,8 @@ async function fetchCrowdfunding() {
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) });
if (name && !isNaN(current) && !isNaN(target)) {
items.push({ name, current, target, pct: target > 0 ? Math.min(100, (current / target) * 100) : 0 });
}
}
});

View File

@@ -91,7 +91,7 @@ function setProject(p) {
<p class="hero-subtitle">因为有你们白鹿原才能走得更远</p>
</section>
<main class="sponsor-container bl-shell">
<main class="sponsor-container">
<!-- Controls -->
<div class="controls-section">
<h2 class="section-title sponsor-list-title"> 赞助列表</h2>
@@ -195,171 +195,242 @@ function setProject(p) {
<style scoped>
/* Hero */
.sponsor-hero {
padding: calc(var(--bl-header-height) + 60px) 20px 60px;
padding: calc(var(--bl-header-height) + 60px) 20px 50px;
text-align: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #fff;
background: radial-gradient(circle at center, rgba(0,113,227,0.08) 0%, rgba(255,255,255,0) 70%);
position: relative;
overflow: hidden;
}
.sponsor-hero h1 {
font-size: 48px;
font-weight: 700;
font-size: 56px;
font-weight: 800;
margin: 0 0 24px;
text-shadow: 0 2px 10px rgba(0, 0, 0, 0.15);
background: linear-gradient(135deg, #1d1d1f 0%, #434344 100%);
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
letter-spacing: -0.02em;
}
.total-donations {
display: flex;
display: inline-flex;
flex-direction: column;
align-items: center;
margin-bottom: 16px;
background: rgba(255, 255, 255, 0.8);
backdrop-filter: blur(20px);
padding: 20px 40px;
border-radius: var(--bl-radius-lg);
box-shadow: 0 10px 40px rgba(0,0,0,0.06);
border: 1px solid rgba(255,255,255,0.6);
transform: translateY(0);
transition: transform 0.3s ease;
}
.total-donations:hover {
transform: translateY(-5px);
}
.counter-label {
font-size: 16px;
opacity: 0.85;
font-size: 14px;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--bl-text-secondary);
margin-bottom: 8px;
font-weight: 600;
}
.counter-value {
font-size: 56px;
font-size: 42px;
font-weight: 800;
font-family: 'Inter', sans-serif;
letter-spacing: -1px;
color: #34c759;
font-feature-settings: "tnum";
font-variant-numeric: tabular-nums;
}
.hero-subtitle {
font-size: 20px;
opacity: 0.9;
margin: 0;
font-size: 18px;
color: var(--bl-text-secondary);
margin-top: 16px;
max-width: 600px;
margin-left: auto;
margin-right: auto;
}
/* Container */
.sponsor-container {
max-width: 1100px;
margin: 0 auto;
padding: 40px 20px;
}
/* Controls */
.controls-section {
margin-bottom: 32px;
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
margin-bottom: 40px;
max-width: 800px;
margin-left: auto;
margin-right: auto;
}
.section-title {
font-size: 22px;
font-size: 28px;
font-weight: 700;
margin: 0 0 20px;
margin: 0 0 30px;
text-align: center;
}
.sponsor-list-title {
margin-bottom: 10px;
}
.controls-header {
display: flex;
gap: 16px;
width: 100%;
justify-content: center;
align-items: center;
margin-bottom: 16px;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 24px;
}
.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;
position: relative;
flex-grow: 0;
width: 100%;
max-width: 320px;
}
.search-box i {
position: absolute;
left: 16px;
top: 50%;
transform: translateY(-50%);
color: var(--bl-text-secondary);
font-size: 14px;
}
.search-box input {
border: none;
outline: none;
background: transparent;
font-size: 15px;
width: 100%;
height: 48px;
padding: 0 20px 0 44px;
border-radius: 99px;
border: 1px solid rgba(0,0,0,0.1);
background: white;
font-size: 15px;
outline: none;
transition: all 0.2s;
color: var(--bl-text);
font-family: inherit;
box-sizing: border-box;
}
.search-box input:focus {
border-color: var(--bl-accent);
box-shadow: 0 0 0 3px rgba(0,113,227,0.1);
}
.cta-button {
height: 48px;
padding: 0 24px;
background-color: var(--bl-text);
color: white;
border-radius: 99px;
text-decoration: none;
font-weight: 600;
font-size: 14px;
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 24px;
border-radius: 12px;
font-size: 15px;
font-weight: 600;
transition: all 0.2s;
cursor: pointer;
transition: var(--bl-transition);
border: none;
border: 1px solid transparent;
white-space: nowrap;
box-sizing: border-box;
font-family: inherit;
}
.cta-button:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
}
.cta-button.outline {
background: transparent;
border: 2px solid var(--bl-accent);
color: var(--bl-accent);
background-color: transparent;
color: var(--bl-text);
border: 1px solid rgba(0,0,0,0.1);
}
.cta-button.outline:hover {
background: var(--bl-accent);
color: #fff;
border-color: var(--bl-text);
background-color: white;
}
.filter-tags {
display: flex;
gap: 8px;
flex-wrap: wrap;
justify-content: center;
gap: 10px;
}
.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);
padding: 8px 16px;
border-radius: 99px;
border: none;
background: white;
color: var(--bl-text-secondary);
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
box-shadow: 0 2px 8px rgba(0,0,0,0.03);
font-family: inherit;
}
.filter-tag:hover {
border-color: var(--bl-accent);
color: var(--bl-accent);
transform: translateY(-1px);
background: #fafafa;
}
.filter-tag.active {
background: var(--bl-accent);
color: #fff;
border-color: var(--bl-accent);
background: var(--bl-text);
color: white;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
}
/* Donation Grid */
.donation-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 20px;
}
.donation-card {
background: var(--bl-surface-strong);
background: white;
padding: 24px;
border-radius: var(--bl-radius-lg);
padding: 20px;
box-shadow: var(--bl-shadow-soft);
transition: var(--bl-transition);
display: flex;
flex-direction: column;
justify-content: space-between;
position: relative;
overflow: hidden;
border: 1px solid rgba(0, 0, 0, 0.03);
animation: fadeInUp 0.5s ease both;
}
.donation-card:hover {
transform: translateY(-5px);
box-shadow: 0 10px 30px rgba(0,0,0,0.08);
border-color: transparent;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(16px);
transform: translateY(20px);
}
to {
opacity: 1;
@@ -370,8 +441,8 @@ function setProject(p) {
.donation-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 14px;
align-items: flex-start;
margin-bottom: 16px;
}
.donor-info {
@@ -383,57 +454,72 @@ function setProject(p) {
.mini-avatar {
width: 40px;
height: 40px;
border-radius: 10px;
background: #eee;
border-radius: 50%;
background: #f0f0f0;
}
.donor-name {
font-size: 16px;
font-weight: 600;
font-weight: 700;
line-height: 1.2;
}
.donation-amount {
font-size: 20px;
font-weight: 700;
color: var(--bl-accent);
font-family: 'Inter', sans-serif;
color: #34c759;
font-weight: 800;
font-size: 18px;
background: rgba(52, 199, 89, 0.1);
padding: 4px 10px;
border-radius: 8px;
}
.donation-card-body {
flex-grow: 1;
display: flex;
justify-content: space-between;
align-items: center;
padding-top: 12px;
border-top: 1px solid rgba(0, 0, 0, 0.05);
flex-direction: column;
}
.donation-purpose {
font-size: 13px;
color: var(--bl-text-secondary);
font-weight: 500;
color: var(--bl-text);
background: var(--bl-surface-strong);
padding: 6px 12px;
border-radius: 6px;
display: inline-block;
margin-bottom: 12px;
align-self: flex-start;
}
.donation-date {
font-size: 12px;
color: var(--bl-text-secondary);
display: flex;
align-items: center;
gap: 4px;
color: #999;
text-align: right;
margin-top: auto;
border-top: 1px solid rgba(0, 0, 0, 0.05);
padding-top: 12px;
}
.donation-date-icon {
font-size: 11px;
margin-right: 4px;
}
/* Modal */
.modal-gift-icon {
text-align: center;
margin-bottom: 12px;
width: 50px;
height: 50px;
background: rgba(52, 199, 89, 0.1);
border-radius: 50%;
color: #34c759;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
margin: 0 auto 20px;
}
.modal-gift-icon i {
font-size: 48px;
color: var(--bl-accent);
font-size: 24px;
color: #34c759;
}
.modal-title {
@@ -504,9 +590,8 @@ function setProject(p) {
@media (max-width: 768px) {
.sponsor-hero h1 { font-size: 32px; }
.counter-value { font-size: 40px; }
.counter-value { font-size: 32px; }
.donation-grid { grid-template-columns: 1fr; }
.controls-header { flex-direction: column; }
.search-box { width: 100%; }
}
</style>

View File

@@ -6,6 +6,9 @@ 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';
import EditorModal from '../components/shared/EditorModal.vue';
import JsonOutputModal from '../components/shared/JsonOutputModal.vue';
import { useSortableList, useTagsInput } from '../composables/useEditorHelpers.js';
const route = useRoute();
@@ -19,19 +22,30 @@ const recruitFilter = ref('all');
const modalOpen = ref(false);
const selectedTown = ref(null);
const sharedId = ref(null);
const editMode = ref(false);
const editorOpen = ref(false);
const jsonOutputOpen = ref(false);
const jsonOutputText = ref('');
// 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 = ''; }
}
// Editor form state
const edTitle = ref('');
const edLogo = ref('');
const edGradientFrom = ref('#667eea');
const edGradientTo = ref('#764ba2');
const edScale = ref('small');
const edTownType = ref('building');
const edRecruitment = ref('welcome');
const edDimension = ref('overworld');
const edSecret = ref(false);
const edX = ref('');
const edY = ref('');
const edZ = ref('');
const openSelects = ref({});
const founders = useTagsInput();
const members = useTagsInput();
const introduction = useSortableList();
const dragState = ref({ listName: null, fromIdx: null });
onMounted(() => {
document.addEventListener('keydown', onSecretKey);
fetch('/data/towns.json')
.then(r => r.json())
.then(data => {
@@ -60,23 +74,23 @@ function generateId(item) {
// Filter options
const scaleOptions = [
{ value: 'all', label: '全部' },
{ value: 'small', label: '小型' },
{ value: 'medium', label: '中型' },
{ value: 'large', label: '大型' },
{ value: 'small', label: '小型', iconClass: 'fas fa-user' },
{ value: 'medium', label: '中型', iconClass: 'fas fa-users' },
{ value: 'large', label: '大型', iconClass: 'fas fa-city' },
];
const typeOptions = [
{ value: 'all', label: '全部' },
{ value: 'building', label: '建筑' },
{ value: 'adventure', label: '冒险' },
{ value: 'industry', label: '工业' },
{ value: 'building', label: '建筑', iconClass: 'fas fa-building' },
{ value: 'adventure', label: '冒险', iconClass: 'fas fa-dragon' },
{ value: 'industry', label: '工业', iconClass: 'fas fa-industry' },
];
const recruitOptions = [
{ value: 'all', label: '全部' },
{ value: 'welcome', label: '欢迎加入' },
{ value: 'maybe', label: '可以考虑' },
{ value: 'closed', label: '暂不招人' },
{ value: 'welcome', label: '欢迎加入', iconClass: 'fas fa-door-open' },
{ value: 'maybe', label: '可以考虑', iconClass: 'fas fa-question-circle' },
{ value: 'closed', label: '暂不招人', iconClass: 'fas fa-door-closed' },
];
// Maps
@@ -155,6 +169,162 @@ function onFilterChange({ key, value }) {
function hasLogo(item) {
return item.logo && item.logo.trim() !== '';
}
// ========== Editor ==========
const scaleSelectOptions = [
{ value: 'small', label: '小型5人以下' },
{ value: 'medium', label: '中型2-10人' },
{ value: 'large', label: '大型10人以上' },
];
const typeSelectOptions = [
{ value: 'building', label: '建筑' },
{ value: 'adventure', label: '冒险' },
{ value: 'industry', label: '工业' },
];
const recruitSelectOptions = [
{ value: 'welcome', label: '欢迎加入' },
{ value: 'closed', label: '暂不招人' },
{ value: 'maybe', label: '可以考虑' },
];
const dimensionSelectOptions = [
{ value: 'overworld', label: '主世界' },
{ value: 'nether', label: '下界' },
{ value: 'the_end', label: '末地' },
];
function getSelectLabel(options, value) {
return options.find(o => o.value === value)?.label || value;
}
function toggleSelect(name) {
openSelects.value[name] = !openSelects.value[name];
}
function selectOption(name, value) {
if (name === 'scale') edScale.value = value;
else if (name === 'townType') edTownType.value = value;
else if (name === 'recruitment') edRecruitment.value = value;
else if (name === 'dimension') edDimension.value = value;
openSelects.value[name] = false;
}
function closeAllSelects() {
openSelects.value = {};
}
function normalizeHex(val) {
return /^#[0-9a-fA-F]{6}$/.test((val || '').trim()) ? val.trim() : null;
}
function openEditor(item) {
const g = item ? getGradient(item) : DEFAULT_GRADIENT;
edTitle.value = item ? item.title : '';
edLogo.value = item?.logo || '';
edGradientFrom.value = g.from;
edGradientTo.value = g.to;
edScale.value = item?.scale || 'small';
edTownType.value = item?.townType || 'building';
edRecruitment.value = item?.recruitment || 'welcome';
edDimension.value = item?.dimension || 'overworld';
edSecret.value = item?.coordinatesSecret || false;
edX.value = item?.coordinates ? String(item.coordinates.x) : '';
edY.value = item?.coordinates ? String(item.coordinates.y) : '';
edZ.value = item?.coordinates ? String(item.coordinates.z) : '';
founders.reset(item?.founders || []);
members.reset(item?.members || []);
introduction.reset(item?.introduction || []);
openSelects.value = {};
editorOpen.value = true;
}
function openEditorFromModal(item) {
modalOpen.value = false;
selectedTown.value = null;
nextTick(() => openEditor(item));
}
function onTagKeydown(tagsObj, e) {
if (e.isComposing) return;
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
commitTagInput(tagsObj, e.target);
}
}
function commitTagInput(tagsObj, input) {
const val = input.value.trim();
if (val) {
tagsObj.addTag(val);
input.value = '';
}
}
function onDragStart(listName, idx, e) {
dragState.value = { listName, fromIdx: idx };
e.target.closest('.sortable-item').classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', '');
}
function onDragOver(e) {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
}
function onDragEnter(listName, idx, e) {
if (dragState.value.listName === listName) {
e.target.closest('.sortable-item')?.classList.add('drag-over');
}
}
function onDragLeave(e) {
e.target.closest('.sortable-item')?.classList.remove('drag-over');
}
function onDrop(listName, toIdx, e) {
e.preventDefault();
e.target.closest('.sortable-item')?.classList.remove('drag-over');
if (dragState.value.listName !== listName) return;
introduction.moveItem(dragState.value.fromIdx, toIdx);
}
function onDragEnd(e) {
document.querySelectorAll('.sortable-item').forEach(el => el.classList.remove('dragging', 'drag-over'));
dragState.value = { listName: null, fromIdx: null };
}
function generateJson() {
if (!edTitle.value.trim()) {
alert('请填写城镇名称');
return;
}
const obj = {
title: edTitle.value.trim(),
logo: edLogo.value.trim(),
gradient: {
from: normalizeHex(edGradientFrom.value) || DEFAULT_GRADIENT.from,
to: normalizeHex(edGradientTo.value) || DEFAULT_GRADIENT.to,
},
dimension: edDimension.value,
coordinatesSecret: edSecret.value,
scale: edScale.value,
townType: edTownType.value,
recruitment: edRecruitment.value,
founders: [...founders.tags.value],
members: [...members.tags.value],
introduction: introduction.getCleanItems().map(i => i.type === 'video' ? { type: 'video', content: parseBV(i.content) || i.content } : i),
};
if (!edSecret.value) {
obj.coordinates = {
x: parseInt(edX.value) || 0,
y: parseInt(edY.value) || 0,
z: parseInt(edZ.value) || 0,
};
}
jsonOutputText.value = JSON.stringify(obj, null, 4);
jsonOutputOpen.value = true;
}
</script>
<template>
@@ -174,12 +344,14 @@ function hasLogo(item) {
: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 },
{ key: 'scale', label: '规模', labelIcon: 'fas fa-users', options: scaleOptions, modelValue: scaleFilter },
{ key: 'type', label: '类型', labelIcon: 'fas fa-tag', options: typeOptions, modelValue: typeFilter },
{ key: 'recruit', label: '招募', labelIcon: 'fas fa-door-open', options: recruitOptions, modelValue: recruitFilter },
]"
action-label="新增城镇"
@update:search-value="searchQuery = $event"
@change-filter="onFilterChange"
@action="openEditor(null)"
/>
<!-- Grid -->
@@ -259,7 +431,10 @@ function hasLogo(item) {
:class="['btn-share', { shared: sharedId === generateId(selectedTown) }]"
@click="shareItem(selectedTown)"
>
{{ sharedId === generateId(selectedTown) ? '✓ 已复制' : '🔗 分享' }}
<template v-if="sharedId === generateId(selectedTown)">✓ 已复制</template><template v-else><i class="fas fa-share-alt"></i> 分享</template>
</button>
<button type="button" class="btn-edit" @click="openEditorFromModal(selectedTown)">
<i class="fas fa-pen"></i> 编辑
</button>
</div>
</div>
@@ -282,7 +457,7 @@ function hasLogo(item) {
rel="noopener"
class="map-link"
>
🗺 在地图中查看
<i class="fas fa-map-marked-alt"></i> 在地图中查看
</a>
</p>
</ModalSection>
@@ -328,10 +503,215 @@ function hasLogo(item) {
</ModalSection>
</template>
</BaseModal>
<!-- Editor Modal -->
<EditorModal v-model="editorOpen" title="城镇编辑器" icon="fas fa-city">
<template #preview>
<div class="preview-card">
<div class="preview-banner" :style="edLogo ? { backgroundImage: `url('${edLogo}')`, backgroundSize: 'cover', backgroundPosition: 'center' } : { background: `linear-gradient(135deg, ${edGradientFrom} 0%, ${edGradientTo} 100%)` }">
<i v-if="!edLogo" class="fas fa-city preview-banner-icon"></i>
</div>
<div class="preview-header">
<div class="preview-title">{{ edTitle || '未命名城镇' }}</div>
<div class="modal-badges">
<span :class="['town-badge', 'badge-scale-' + edScale]"><i class="fas" :class="scaleIconMap[edScale]"></i> {{ scaleTextMap[edScale] }}</span>
<span :class="['town-badge', 'badge-type-' + edTownType]"><i class="fas" :class="typeIconMap[edTownType]"></i> {{ typeTextMap[edTownType] }}</span>
<span :class="['town-badge', 'badge-recruit-' + edRecruitment]"><i class="fas" :class="recruitIconMap[edRecruitment]"></i> {{ recruitTextMap[edRecruitment] }}</span>
</div>
</div>
<div class="preview-body">
<div class="preview-section">
<div class="preview-section-title"><i class="fas fa-map-marker-alt"></i> 位置信息</div>
<p v-if="edSecret">坐标保密</p>
<p v-else>{{ getSelectLabel(dimensionSelectOptions, edDimension) }}: X: {{ edX || '0' }}, Y: {{ edY || '64' }}, Z: {{ edZ || '0' }}</p>
</div>
<div class="preview-section">
<div class="preview-section-title"><i class="fas fa-crown"></i> 创始人</div>
<div v-if="founders.tags.value.length" class="contributors-list">
<span v-for="name in founders.tags.value" :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="preview-text-secondary">暂无记录</span>
</div>
<div class="preview-section">
<div class="preview-section-title"><i class="fas fa-users"></i> 主要成员</div>
<div v-if="members.tags.value.length" class="contributors-list">
<span v-for="name in members.tags.value" :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="preview-text-secondary">暂无记录</span>
</div>
<div class="preview-section">
<div class="preview-section-title"><i class="fas fa-scroll"></i> 城镇介绍</div>
<div class="content-blocks" v-if="introduction.items.value.length">
<template v-for="(block, bi) in introduction.items.value" :key="bi">
<p v-if="block.type === 'text'">{{ block.content || '空文字' }}</p>
<img v-else-if="block.type === 'image' && block.content" :src="block.content" loading="lazy" alt="">
<p v-else-if="block.type === 'image'" class="preview-text-secondary">空图片</p>
<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>
<p v-else-if="block.type === 'video'" class="preview-text-secondary">请输入有效的 BV 号或 bilibili 视频地址</p>
</template>
</div>
<p v-else></p>
</div>
</div>
</div>
</template>
<template #form>
<div @click="closeAllSelects">
<div class="form-group">
<label>城镇名称</label>
<input type="text" v-model="edTitle" placeholder="输入城镇名称...">
</div>
<div class="form-group">
<label>头图/Logo 图片地址</label>
<input type="text" v-model="edLogo" placeholder="输入图片URL留空则使用渐变色...">
</div>
<div class="form-row">
<div class="form-group">
<label>卡片渐变起始色</label>
<input type="color" v-model="edGradientFrom">
</div>
<div class="form-group">
<label>卡片渐变结束色</label>
<input type="color" v-model="edGradientTo">
</div>
</div>
<p class="form-hint">当未设置头图时将使用这组渐变色作为卡片和详情头图背景</p>
<div class="form-row">
<div class="form-group">
<label>规模</label>
<div :class="['custom-select', { open: openSelects.scale }]" @click.stop>
<div class="custom-select-trigger" @click="toggleSelect('scale')">
<span>{{ getSelectLabel(scaleSelectOptions, edScale) }}</span>
<i class="fas fa-chevron-down"></i>
</div>
<div class="custom-select-options">
<div v-for="opt in scaleSelectOptions" :key="opt.value" :class="['custom-option', { selected: edScale === opt.value }]" @click="selectOption('scale', opt.value)">{{ opt.label }}</div>
</div>
</div>
</div>
<div class="form-group">
<label>类型</label>
<div :class="['custom-select', { open: openSelects.townType }]" @click.stop>
<div class="custom-select-trigger" @click="toggleSelect('townType')">
<span>{{ getSelectLabel(typeSelectOptions, edTownType) }}</span>
<i class="fas fa-chevron-down"></i>
</div>
<div class="custom-select-options">
<div v-for="opt in typeSelectOptions" :key="opt.value" :class="['custom-option', { selected: edTownType === opt.value }]" @click="selectOption('townType', opt.value)">{{ opt.label }}</div>
</div>
</div>
</div>
<div class="form-group">
<label>招募状态</label>
<div :class="['custom-select', { open: openSelects.recruitment }]" @click.stop>
<div class="custom-select-trigger" @click="toggleSelect('recruitment')">
<span>{{ getSelectLabel(recruitSelectOptions, edRecruitment) }}</span>
<i class="fas fa-chevron-down"></i>
</div>
<div class="custom-select-options">
<div v-for="opt in recruitSelectOptions" :key="opt.value" :class="['custom-option', { selected: edRecruitment === opt.value }]" @click="selectOption('recruitment', opt.value)">{{ opt.label }}</div>
</div>
</div>
</div>
</div>
<div class="form-group">
<label class="toggle-label">
<input type="checkbox" v-model="edSecret" class="toggle-checkbox">
<span class="toggle-switch"></span>
坐标保密
</label>
</div>
<template v-if="!edSecret">
<div class="form-group">
<label>所在世界</label>
<div :class="['custom-select', { open: openSelects.dimension }]" @click.stop>
<div class="custom-select-trigger" @click="toggleSelect('dimension')">
<span>{{ getSelectLabel(dimensionSelectOptions, edDimension) }}</span>
<i class="fas fa-chevron-down"></i>
</div>
<div class="custom-select-options">
<div v-for="opt in dimensionSelectOptions" :key="opt.value" :class="['custom-option', { selected: edDimension === opt.value }]" @click="selectOption('dimension', opt.value)">{{ opt.label }}</div>
</div>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>X 坐标</label>
<input type="number" v-model="edX" placeholder="0">
</div>
<div class="form-group">
<label>Y 坐标</label>
<input type="number" v-model="edY" placeholder="64">
</div>
<div class="form-group">
<label>Z 坐标</label>
<input type="number" v-model="edZ" placeholder="0">
</div>
</div>
</template>
<div class="form-group">
<label>创始人</label>
<div class="tags-input-wrapper" @click="$refs.founderInput.focus()">
<div class="tags-list">
<span v-for="(tag, ti) in founders.tags.value" :key="ti" class="editor-tag">
{{ tag }}
<span class="editor-tag-remove" @click="founders.removeTag(ti)"><i class="fas fa-times"></i></span>
</span>
</div>
<input ref="founderInput" type="text" placeholder="输入名称后按回车或空格添加..." @keydown="onTagKeydown(founders, $event)" @blur="commitTagInput(founders, $event.target)">
</div>
</div>
<div class="form-group">
<label>主要成员</label>
<div class="tags-input-wrapper" @click="$refs.memberInput.focus()">
<div class="tags-list">
<span v-for="(tag, ti) in members.tags.value" :key="ti" class="editor-tag">
{{ tag }}
<span class="editor-tag-remove" @click="members.removeTag(ti)"><i class="fas fa-times"></i></span>
</span>
</div>
<input ref="memberInput" type="text" placeholder="输入名称后按回车或空格添加..." @keydown="onTagKeydown(members, $event)" @blur="commitTagInput(members, $event.target)">
</div>
</div>
<div class="form-group">
<label>城镇介绍</label>
<div class="sortable-list">
<div v-for="(item, idx) in introduction.items.value" :key="idx" class="sortable-item" draggable="true" @dragstart="onDragStart('introduction', idx, $event)" @dragover="onDragOver" @dragenter="onDragEnter('introduction', idx, $event)" @dragleave="onDragLeave" @drop="onDrop('introduction', idx, $event)" @dragend="onDragEnd">
<span class="drag-handle"><i class="fas fa-grip-vertical"></i></span>
<span :class="['item-type-badge', 'badge-' + item.type]">{{ item.type === 'text' ? '文字' : item.type === 'image' ? '图片' : '视频' }}</span>
<textarea v-if="item.type === 'text'" class="item-content" rows="2" placeholder="输入文字内容..." :value="item.content" @input="introduction.updateContent(idx, $event.target.value)"></textarea>
<input v-else-if="item.type === 'image'" type="text" class="item-content" placeholder="输入图片URL..." :value="item.content" @input="introduction.updateContent(idx, $event.target.value)">
<input v-else type="text" class="item-content" placeholder="BV1xxxxxxxxxx 或 bilibili 视频地址" :value="item.content" @input="introduction.updateContent(idx, $event.target.value)">
<button type="button" class="remove-item-btn" @click="introduction.removeItem(idx)"><i class="fas fa-trash-alt"></i></button>
</div>
</div>
<div class="add-item-row">
<button type="button" class="add-item-btn" @click="introduction.addItem('text')"><i class="fas fa-plus"></i> 添加文字</button>
<button type="button" class="add-item-btn" @click="introduction.addItem('image')"><i class="fas fa-image"></i> 添加图片</button>
<button type="button" class="add-item-btn" @click="introduction.addItem('video')"><i class="fas fa-video"></i> 添加视频</button>
</div>
</div>
<div class="editor-actions">
<button type="button" class="btn-generate-json" @click="generateJson"><i class="fas fa-save"></i> 生成 JSON</button>
</div>
</div>
</template>
</EditorModal>
<JsonOutputModal v-model="jsonOutputOpen" :json-text="jsonOutputText" />
</main>
</template>
<style scoped>
@import '../styles/editor-form.css';
.towns-hero {
height: 35vh;
min-height: 300px;
@@ -571,6 +951,26 @@ function hasLogo(item) {
background: #e8fceb;
}
.btn-edit {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 16px;
background: transparent;
color: var(--bl-accent);
border: 1.5px solid var(--bl-accent);
border-radius: 18px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: var(--bl-transition);
}
.btn-edit:hover {
background: var(--bl-accent);
color: #fff;
}
.map-link {
display: inline-flex;
align-items: center;
@@ -671,4 +1071,82 @@ function hasLogo(item) {
.modal-header-inner h3 { font-size: 24px; }
.town-modal-banner { height: 140px; }
}
/* Editor-specific */
.preview-banner {
height: 160px;
border-radius: 16px 16px 0 0;
display: flex;
align-items: center;
justify-content: center;
}
.preview-banner-icon {
font-size: 48px;
color: rgba(255, 255, 255, 0.4);
}
.form-hint {
font-size: 12px;
color: var(--bl-text-secondary);
margin: -14px 0 18px;
line-height: 1.5;
}
.form-group input[type="color"] {
width: 100%;
height: 44px;
padding: 4px;
border: 1.5px solid rgba(0,0,0,0.1);
border-radius: 12px;
background: #f9f9fa;
cursor: pointer;
box-sizing: border-box;
}
.toggle-label {
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
font-size: 14px;
font-weight: 600;
color: var(--bl-text);
user-select: none;
}
.toggle-checkbox {
display: none;
}
.toggle-switch {
position: relative;
width: 44px;
height: 24px;
background: #ccc;
border-radius: 12px;
transition: 0.3s;
flex-shrink: 0;
}
.toggle-switch::after {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: 20px;
height: 20px;
background: #fff;
border-radius: 50%;
transition: 0.3s;
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
}
.toggle-checkbox:checked + .toggle-switch {
background: var(--bl-accent);
}
.toggle-checkbox:checked + .toggle-switch::after {
transform: translateX(20px);
}
</style>

443
src/styles/editor-form.css Normal file
View File

@@ -0,0 +1,443 @@
/* Shared editor form styles — imported in page-specific <style> blocks */
.form-group {
margin-bottom: 22px;
}
.form-group > label {
display: block;
font-size: 12px;
font-weight: 700;
color: var(--bl-text-secondary);
margin-bottom: 8px;
text-transform: uppercase;
letter-spacing: 0.4px;
}
.form-group input[type="text"],
.form-group input[type="number"],
.form-group input[type="date"],
.form-group textarea {
width: 100%;
padding: 12px 16px;
border: 1.5px solid rgba(0,0,0,0.1);
border-radius: 12px;
font-size: 14px;
font-family: inherit;
background-color: #f9f9fa;
transition: all 0.2s ease;
color: var(--bl-text);
box-sizing: border-box;
}
.form-group input:focus,
.form-group textarea:focus {
outline: none;
border-color: var(--bl-accent);
background-color: #fff;
box-shadow: 0 0 0 4px rgba(0, 113, 227, 0.1);
}
.form-group textarea {
resize: vertical;
min-height: 60px;
}
.form-row {
display: flex;
gap: 14px;
}
.form-row .form-group {
flex: 1;
}
/* Custom Select */
.custom-select {
position: relative;
width: 100%;
user-select: none;
font-size: 14px;
}
.custom-select-trigger {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
padding: 12px 16px;
border: 1.5px solid rgba(0,0,0,0.1);
border-radius: 12px;
background: #f9f9fa;
color: var(--bl-text);
cursor: pointer;
transition: all 0.2s ease;
box-sizing: border-box;
}
.custom-select-trigger i {
color: var(--bl-text-secondary);
font-size: 12px;
transition: transform 0.3s;
}
.custom-select:hover .custom-select-trigger {
background: #fff;
border-color: rgba(0,0,0,0.2);
}
.custom-select.open .custom-select-trigger {
border-color: var(--bl-accent);
background: #fff;
box-shadow: 0 0 0 4px rgba(0, 113, 227, 0.1);
}
.custom-select.open .custom-select-trigger i {
transform: rotate(180deg);
}
.custom-select-options {
position: absolute;
top: calc(100% + 8px);
left: 0;
right: 0;
background: #fff;
border-radius: 12px;
box-shadow: 0 10px 40px rgba(0,0,0,0.15);
border: 1px solid rgba(0,0,0,0.08);
opacity: 0;
visibility: hidden;
transform: translateY(-10px);
transition: all 0.2s cubic-bezier(0.25, 1, 0.5, 1);
z-index: 100;
padding: 8px;
}
.custom-select.open .custom-select-options {
opacity: 1;
visibility: visible;
transform: translateY(0);
}
.custom-option {
padding: 10px 14px;
border-radius: 8px;
cursor: pointer;
transition: background 0.2s, color 0.2s;
color: var(--bl-text);
margin-bottom: 2px;
}
.custom-option:last-child {
margin-bottom: 0;
}
.custom-option:hover {
background: #f5f5f7;
}
.custom-option.selected {
background: #e0f2fe;
color: #0369a1;
font-weight: 600;
}
/* Tags Input */
.tags-input-wrapper {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
padding: 8px 12px;
border: 1.5px solid rgba(0,0,0,0.1);
border-radius: 12px;
background: #f9f9fa;
transition: border-color 0.2s, background 0.2s, box-shadow 0.2s;
cursor: text;
}
.tags-input-wrapper:focus-within {
border-color: var(--bl-accent);
background: #fff;
box-shadow: 0 0 0 4px rgba(0, 113, 227, 0.1);
}
.tags-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.editor-tag {
display: inline-flex;
align-items: center;
gap: 6px;
background: var(--bl-accent);
color: #fff;
padding: 4px 10px;
border-radius: 14px;
font-size: 12px;
font-weight: 600;
}
.editor-tag-remove {
cursor: pointer;
opacity: 0.7;
transition: 0.2s;
font-size: 10px;
}
.editor-tag-remove:hover {
opacity: 1;
}
.tags-input-wrapper input {
border: none !important;
background: transparent !important;
padding: 4px 0 !important;
font-size: 13px;
flex: 1;
min-width: 140px;
box-shadow: none !important;
}
.tags-input-wrapper input:focus {
outline: none;
}
/* Sortable Content List */
.sortable-list {
min-height: 8px;
margin-bottom: 10px;
}
.sortable-item {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 10px 12px;
margin-bottom: 8px;
background: #fff;
border: 1.5px solid rgba(0,0,0,0.08);
border-radius: 12px;
transition: box-shadow 0.2s, border-color 0.2s, opacity 0.2s;
}
.sortable-item:last-child {
margin-bottom: 0;
}
.sortable-item:hover {
border-color: rgba(0,0,0,0.15);
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
}
.sortable-item.dragging {
opacity: 0.4;
border-color: var(--bl-accent);
}
.sortable-item.drag-over {
border-color: var(--bl-accent);
box-shadow: 0 0 0 2px rgba(0, 113, 227, 0.15);
}
.drag-handle {
cursor: grab;
color: var(--bl-text-secondary);
padding: 6px 2px;
font-size: 14px;
opacity: 0.35;
transition: 0.2s;
flex-shrink: 0;
}
.drag-handle:active {
cursor: grabbing;
}
.sortable-item:hover .drag-handle {
opacity: 0.7;
}
.item-type-badge {
font-size: 10px;
font-weight: 700;
padding: 3px 8px;
border-radius: 6px;
white-space: nowrap;
flex-shrink: 0;
margin-top: 6px;
text-transform: uppercase;
letter-spacing: 0.3px;
}
.badge-text {
background: #e8f5e9;
color: #2e7d32;
}
.badge-image {
background: #e3f2fd;
color: #1565c0;
}
.badge-video {
background: #fce4ec;
color: #c62828;
}
.sortable-item .item-content {
flex: 1;
border: 1px solid rgba(0,0,0,0.06) !important;
border-radius: 8px !important;
padding: 8px 10px !important;
font-size: 13px !important;
background: #fafafa !important;
min-height: unset;
font-family: inherit;
resize: vertical;
width: 100%;
box-sizing: border-box;
}
.sortable-item .item-content:focus {
border-color: var(--bl-accent) !important;
background: #fff !important;
box-shadow: none !important;
outline: none;
}
.remove-item-btn {
background: none;
border: none;
color: #ccc;
cursor: pointer;
padding: 6px;
border-radius: 8px;
transition: 0.2s;
flex-shrink: 0;
margin-top: 3px;
font-size: 13px;
}
.remove-item-btn:hover {
color: #ef4444;
background: #fef2f2;
}
.add-item-row {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.add-item-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
background: #f5f5f7;
border: 1.5px dashed rgba(0,0,0,0.12);
border-radius: 10px;
font-size: 12px;
font-weight: 600;
color: var(--bl-text-secondary);
cursor: pointer;
transition: 0.2s;
font-family: inherit;
}
.add-item-btn:hover {
border-color: var(--bl-accent);
color: var(--bl-accent);
background: #f0f7ff;
}
/* Editor Actions */
.editor-actions {
margin-top: 28px;
padding-top: 20px;
border-top: 1px solid rgba(0,0,0,0.08);
display: flex;
justify-content: flex-end;
}
.btn-generate-json {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 12px 28px;
background: #34c759;
color: #fff;
border: none;
border-radius: 14px;
font-size: 15px;
font-weight: 700;
cursor: pointer;
transition: 0.2s;
font-family: inherit;
}
.btn-generate-json:hover {
background: #2db84d;
transform: translateY(-1px);
box-shadow: 0 4px 16px rgba(52, 199, 89, 0.3);
}
/* Preview styles */
.preview-card {
background: #fff;
border-radius: 16px;
box-shadow: 0 2px 12px rgba(0,0,0,0.06);
overflow: hidden;
}
.preview-header {
padding: 24px 24px 16px;
background: linear-gradient(to bottom, #fff, #fafafa);
border-bottom: 1px solid rgba(0,0,0,0.05);
}
.preview-title {
font-size: 22px;
font-weight: 700;
margin-bottom: 12px;
line-height: 1.2;
}
.preview-body {
padding: 20px 24px 24px;
}
.preview-intro {
font-size: 15px;
line-height: 1.6;
color: var(--bl-text);
margin-bottom: 20px;
}
.preview-section {
margin-top: 20px;
}
.preview-section-title {
font-size: 14px;
font-weight: 700;
margin: 0 0 10px;
display: flex;
align-items: center;
gap: 8px;
color: var(--bl-text);
}
.preview-text-secondary {
color: var(--bl-text-secondary);
}
@media (max-width: 900px) {
.form-row {
flex-direction: column;
gap: 0;
}
}