diff --git a/src/components/base/BaseModal.vue b/src/components/base/BaseModal.vue
index 39c6da0..49d78de 100644
--- a/src/components/base/BaseModal.vue
+++ b/src/components/base/BaseModal.vue
@@ -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 {
diff --git a/src/components/base/FilterTagGroup.vue b/src/components/base/FilterTagGroup.vue
index 971fb1f..4d20bed 100644
--- a/src/components/base/FilterTagGroup.vue
+++ b/src/components/base/FilterTagGroup.vue
@@ -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']);
-
{{ label }}
+
+
+ {{ label }}
+
@@ -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 {
diff --git a/src/components/layout/MobileNavDrawer.vue b/src/components/layout/MobileNavDrawer.vue
index 9b8c8b6..a65777c 100644
--- a/src/components/layout/MobileNavDrawer.vue
+++ b/src/components/layout/MobileNavDrawer.vue
@@ -1,7 +1,7 @@
-
-
-
+
+
+
\ No newline at end of file
diff --git a/src/components/layout/SiteNavbar.vue b/src/components/layout/SiteNavbar.vue
index 5ff9d45..a49316b 100644
--- a/src/components/layout/SiteNavbar.vue
+++ b/src/components/layout/SiteNavbar.vue
@@ -41,12 +41,10 @@ const isActive = (href) => href === props.activePath;
@@ -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;
}
}
\ No newline at end of file
diff --git a/src/components/shared/EditorModal.vue b/src/components/shared/EditorModal.vue
new file mode 100644
index 0000000..2fec6b1
--- /dev/null
+++ b/src/components/shared/EditorModal.vue
@@ -0,0 +1,247 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/shared/FilterPanel.vue b/src/components/shared/FilterPanel.vue
index 1683ab1..0893083 100644
--- a/src/components/shared/FilterPanel.vue
+++ b/src/components/shared/FilterPanel.vue
@@ -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 })"
diff --git a/src/components/shared/JsonOutputModal.vue b/src/components/shared/JsonOutputModal.vue
new file mode 100644
index 0000000..1d7ae06
--- /dev/null
+++ b/src/components/shared/JsonOutputModal.vue
@@ -0,0 +1,180 @@
+
+
+
+
+
+
+
+
+
生成完成
+
请复制以下 JSON 内容,发送给服主以更新到网站上。
+
+
+
+
+
+
+
+
+
diff --git a/src/composables/useEditorHelpers.js b/src/composables/useEditorHelpers.js
new file mode 100644
index 0000000..9c808f7
--- /dev/null
+++ b/src/composables/useEditorHelpers.js
@@ -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 };
+}
diff --git a/src/pages/AnnouncementsPage.vue b/src/pages/AnnouncementsPage.vue
index 4e8c9c6..7f339e8 100644
--- a/src/pages/AnnouncementsPage.vue
+++ b/src/pages/AnnouncementsPage.vue
@@ -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;
+}
@@ -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)"
/>
@@ -149,14 +251,15 @@ function onFilterChange({ key, value }) {
@@ -182,7 +285,10 @@ function onFilterChange({ key, value }) {
:class="['btn-share', { shared: sharedId === generateAnchorId(item) }]"
@click="shareItem(item, $event)"
>
- {{ sharedId === generateAnchorId(item) ? '✓ 已复制链接' : '🔗 分享' }}
+ ✓ 已复制链接 分享
+
+
@@ -192,10 +298,99 @@ function onFilterChange({ key, value }) {
+
+
+
+
+
+
+
+
{{ edIntro || '暂无简介' }}
+
+
+ {{ block.content || '空文字' }}
+
+ 空图片
+
+
+
+ 请输入有效的 BV 号或 bilibili 视频地址
+
+
+
无
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/pages/FacilitiesPage.vue b/src/pages/FacilitiesPage.vue
index ffb8254..71313af 100644
--- a/src/pages/FacilitiesPage.vue
+++ b/src/pages/FacilitiesPage.vue
@@ -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;
+}
@@ -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)"
/>
@@ -179,12 +328,12 @@ function onFilterChange({ key, value }) {
{{ selectedFacility.intro }}
-
+
{{ statusTextMap[selectedFacility.status] }}
-
-
+
+
{{ typeTextMap[selectedFacility.type] }}
-
+
+
@@ -213,7 +365,7 @@ function onFilterChange({ key, value }) {
rel="noopener"
class="map-link"
>
- 🗺️ 在地图中查看
+ 在地图中查看
@@ -262,10 +414,192 @@ function onFilterChange({ key, value }) {
+
+
+
+
+
+
+
+
{{ edIntro || '暂无简介' }}
+
+
位置信息
+
{{ dimensionTextMap[edDimension] }}: X: {{ edX || '0' }}, Y: {{ edY || '64' }}, Z: {{ edZ || '0' }}
+
+
+
贡献/维护人员
+
+
+
+ {{ name }}
+
+
+
暂无记录
+
+
+
使用说明
+
+
+ {{ block.content || '空文字' }}
+
+ 空图片
+
+
+
+ 请输入有效的 BV 号或 bilibili 视频地址
+
+
+
无
+
+
+
注意事项
+
+
+ {{ block.content || '空文字' }}
+
+ 空图片
+
+
+
+ 请输入有效的 BV 号或 bilibili 视频地址
+
+
+
无
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/pages/TownsPage.vue b/src/pages/TownsPage.vue
index 2827718..5264c4d 100644
--- a/src/pages/TownsPage.vue
+++ b/src/pages/TownsPage.vue
@@ -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;
+}
@@ -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)"
/>
@@ -259,7 +431,10 @@ function hasLogo(item) {
:class="['btn-share', { shared: sharedId === generateId(selectedTown) }]"
@click="shareItem(selectedTown)"
>
- {{ sharedId === generateId(selectedTown) ? '✓ 已复制' : '🔗 分享' }}
+ ✓ 已复制 分享
+
+
@@ -282,7 +457,7 @@ function hasLogo(item) {
rel="noopener"
class="map-link"
>
- 🗺️ 在地图中查看
+ 在地图中查看
@@ -328,10 +503,215 @@ function hasLogo(item) {
+
+
+
+
+
+
+
+
+
+
+
+
位置信息
+
坐标保密
+
{{ getSelectLabel(dimensionSelectOptions, edDimension) }}: X: {{ edX || '0' }}, Y: {{ edY || '64' }}, Z: {{ edZ || '0' }}
+
+
+
创始人
+
+
+
{{ name }}
+
+
+
暂无记录
+
+
+
主要成员
+
+
+
{{ name }}
+
+
+
暂无记录
+
+
+
城镇介绍
+
+
+ {{ block.content || '空文字' }}
+
+ 空图片
+
+
+
+ 请输入有效的 BV 号或 bilibili 视频地址
+
+
+
无
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
当未设置头图时,将使用这组渐变色作为卡片和详情头图背景。
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/styles/editor-form.css b/src/styles/editor-form.css
new file mode 100644
index 0000000..f2cfe59
--- /dev/null
+++ b/src/styles/editor-form.css
@@ -0,0 +1,443 @@
+/* Shared editor form styles — imported in page-specific