mirror of
https://github.com/Coldsmiles/infstarweb.git
synced 2026-04-23 02:30:41 +08:00
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:
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
247
src/components/shared/EditorModal.vue
Normal file
247
src/components/shared/EditorModal.vue
Normal 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">×</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>
|
||||
@@ -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 })"
|
||||
|
||||
180
src/components/shared/JsonOutputModal.vue
Normal file
180
src/components/shared/JsonOutputModal.vue
Normal 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">×</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>
|
||||
63
src/composables/useEditorHelpers.js
Normal file
63
src/composables/useEditorHelpers.js
Normal 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 };
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
443
src/styles/editor-form.css
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user