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']); 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; +} + + + + + + + + + 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