diff --git a/DESIGN.md b/DESIGN.md index 16d19e7..675e09f 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -638,6 +638,7 @@ Items 与 Event Items 使用相同数据模型: - 按用途筛选 - 按标签筛选 - 按自定义排序展示 +- All 视图在满足写入权限时支持对 Grid Item 右键插入新物品到前/后,并支持直接拖曳 Item 调整排序;插入与拖曳只作用于当前展示的 Items 列表,不影响 Event Items 入口。 - 新增物品入口支持当前浏览器 Session 的默认值菜单;用户可为新建物品预设分类、客制化勾选项和入手方式。默认值只影响 `/items/new` 与 `/event-items/new` 的新建表单初始值,不影响编辑已有物品,不改变 API、数据库模型、权限或审计行为;Event Items 仍由 `/event-items/new` 入口决定 `is_event_item`。 - 物品列表桌面端使用 12 列紧凑 Grid,每个格子只展示物品图标;有用途的物品在卡片左上角以斜 Ribbon 展示用途名称;物品名称通过 hover / focus Tooltip 展示。 - 物品列表移动端保持常规卡片布局,展示物品图标、名称和分类。 diff --git a/backend/src/queries.ts b/backend/src/queries.ts index 4178e8f..0e3a0d2 100644 --- a/backend/src/queries.ts +++ b/backend/src/queries.ts @@ -216,6 +216,8 @@ type ItemPayload = { acquisitionMethodIds: number[]; tagIds: number[]; imagePath: string; + insertBeforeItemId: number | null; + insertAfterItemId: number | null; }; type AncientArtifactPayload = { @@ -6477,9 +6479,15 @@ function cleanItemPayload(payload: Record): ItemPayload { const usageId = payload.usageId === null || payload.usageId === '' || payload.usageId === undefined ? null : requirePositiveInteger(payload.usageId, 'server.validation.usageRequired'); + const insertBeforeItemId = cleanOptionalPositiveInteger(payload.insertBeforeItemId); + const insertAfterItemId = cleanOptionalPositiveInteger(payload.insertAfterItemId); const category = systemListOptionById(itemCategoryOptions, categoryId, 'server.validation.categoryRequired'); const usage = usageId === null ? null : systemListOptionById(itemUsageOptions, usageId, 'server.validation.usageRequired'); + if (insertBeforeItemId !== null && insertAfterItemId !== null) { + throw validationError('server.validation.invalidField'); + } + return { name: cleanName(payload.name, 'server.validation.itemNameRequired'), details: cleanOptionalText(payload.details), @@ -6495,10 +6503,28 @@ function cleanItemPayload(payload: Record): ItemPayload { isEventItem: Boolean(payload.isEventItem), acquisitionMethodIds: cleanIds(payload.acquisitionMethodIds), tagIds: cleanIds(payload.tagIds), - imagePath: cleanUploadImagePath(payload.imagePath, 'items') + imagePath: cleanUploadImagePath(payload.imagePath, 'items'), + insertBeforeItemId, + insertAfterItemId }; } +function cleanOptionalPositiveInteger(value: unknown): number | null { + if (value === null || value === '' || value === undefined) { + return null; + } + + return requirePositiveInteger(value, 'server.validation.invalidField'); +} + +async function orderedItemIds(client: DbClient, isEventItem: boolean): Promise { + const rows = await client.query<{ id: number }>( + 'SELECT id FROM items WHERE is_event_item = $1 ORDER BY sort_order, id', + [isEventItem] + ); + return rows.rows.map((row) => row.id); +} + async function ensureItemCanDisableRecipe(client: DbClient, itemId: number, noRecipe: boolean): Promise { if (!noRecipe) { return; @@ -6573,6 +6599,28 @@ export async function createItem(payload: Record, userId: numbe await linkEntityImageUpload(client, 'items', itemId, cleanPayload.imagePath, cleanPayload.name); await replaceItemRelations(client, itemId, cleanPayload); await replaceEntityTranslations(client, 'items', itemId, cleanPayload.translations, ['name', 'details']); + + if (cleanPayload.insertBeforeItemId !== null || cleanPayload.insertAfterItemId !== null) { + const targetId = cleanPayload.insertBeforeItemId ?? cleanPayload.insertAfterItemId; + if (targetId === null) { + throw validationError('server.validation.invalidField'); + } + + const orderedIds = await orderedItemIds(client, cleanPayload.isEventItem); + const targetIndex = orderedIds.indexOf(targetId); + if (targetIndex < 0) { + throw validationError('server.validation.recordMissing'); + } + + const insertedIndex = orderedIds.indexOf(itemId); + if (insertedIndex >= 0) { + orderedIds.splice(insertedIndex, 1); + } + + orderedIds.splice(targetIndex + (cleanPayload.insertAfterItemId !== null ? 1 : 0), 0, itemId); + await reorderTableRows(client, 'items', 'items', orderedIds, userId); + } + await recordEditLog(client, 'items', itemId, 'create', userId); return itemId; }); diff --git a/backend/src/server.ts b/backend/src/server.ts index 36b4d8d..aba4f50 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -1796,9 +1796,21 @@ app.get('/api/items/:id', async (request, reply) => { app.post('/api/items', async (request, reply) => { const user = await requirePermissionWithRateLimits(request, reply, 'items.create', 'wikiWrite'); - return user - ? reply.code(201).send(await createItem(request.body as Record, user.id, requestLocale(request))) - : undefined; + if (!user) { + return undefined; + } + + const payload = request.body as Record; + const hasInsertAnchor = + (payload.insertBeforeItemId !== undefined && payload.insertBeforeItemId !== null && payload.insertBeforeItemId !== '') || + (payload.insertAfterItemId !== undefined && payload.insertAfterItemId !== null && payload.insertAfterItemId !== ''); + + if (hasInsertAnchor && !userHasPermission(user, 'items.order')) { + reply.code(403).send({ message: await serverMessage(requestLocale(request), 'permissionDenied') }); + return undefined; + } + + return reply.code(201).send(await createItem(payload, user.id, requestLocale(request))); }); app.put('/api/items/:id', async (request, reply) => { diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 9c9c7fb..a99a117 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -800,6 +800,8 @@ export interface ItemPayload { acquisitionMethodIds: number[]; tagIds: number[]; imagePath: string; + insertBeforeItemId?: number | null; + insertAfterItemId?: number | null; } export interface AncientArtifactPayload { diff --git a/frontend/src/styles/main.css b/frontend/src/styles/main.css index e7b730f..e1d6243 100644 --- a/frontend/src/styles/main.css +++ b/frontend/src/styles/main.css @@ -2615,6 +2615,121 @@ button:disabled, opacity: 1; } +.item-grid-slot { + position: relative; + min-width: 0; +} + +.item-grid-slot .entity-card { + width: 100%; + height: 100%; +} + +.item-grid-slot .entity-card, +.item-grid-slot .entity-card__image { + -webkit-user-drag: none; +} + +.item-grid-card--interactive { + cursor: grab; + touch-action: manipulation; + user-select: none; +} + +.item-grid-card--interactive:active { + cursor: grabbing; +} + +.item-grid-slot.is-dragging { + z-index: 4; + opacity: 0.72; + transform: scale(0.99); +} + +.item-grid-slot.is-dragging .entity-card { + background: color-mix(in srgb, var(--pokemon-yellow) 12%, var(--surface)); + box-shadow: var(--shadow-soft); +} + +.item-grid-slot.is-drop-target::before { + content: ""; + position: absolute; + right: 0; + left: 0; + z-index: 6; + height: 3px; + border-radius: 999px; + background: var(--pokemon-blue); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--pokemon-blue) 18%, transparent); +} + +.item-grid-slot.is-drop-before::before { + top: 0; +} + +.item-grid-slot.is-drop-after::before { + bottom: 0; +} + +.item-grid-move, +.item-grid-enter-active, +.item-grid-leave-active { + transition: + transform 0.22s cubic-bezier(0.2, 0.8, 0.2, 1), + opacity 0.18s ease; +} + +.item-grid-enter-from, +.item-grid-leave-to { + opacity: 0; + transform: scale(0.94); +} + +.item-grid-leave-active { + position: absolute; +} + +.item-context-menu { + position: fixed; + z-index: 60; + width: min(216px, calc(100vw - 32px)); + display: grid; + gap: 4px; + padding: 8px; + border: 2px solid var(--line-strong); + border-radius: var(--radius-card); + background: var(--surface); + box-shadow: var(--shadow-raised); +} + +.item-context-menu__option { + min-height: 44px; + display: inline-flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 10px 12px; + border: 1px solid var(--line); + border-radius: var(--radius-control); + background: var(--surface-soft); + color: var(--ink-soft); + font-weight: 850; + cursor: pointer; + text-align: left; +} + +.item-context-menu__option:hover, +.item-context-menu__option:focus-visible { + border-color: var(--pokemon-blue); + background: color-mix(in srgb, var(--pokemon-blue) 9%, var(--surface-soft)); + color: var(--pokemon-blue-deep); +} + +.item-context-menu__option .ui-icon { + width: 20px; + height: 20px; +} + .catalog-card-action { min-height: 36px; max-width: 100%; @@ -4061,6 +4176,10 @@ button:disabled, .sidebar-tooltip, .side-nav__link, .side-nav__chevron, + .item-grid-slot, + .item-grid-move, + .item-grid-enter-active, + .item-grid-leave-active, .reorderable-row, .reorderable-list-move, .drag-handle { @@ -4068,6 +4187,9 @@ button:disabled, } .life-page .ui-button:hover, + .item-grid-enter-from, + .item-grid-leave-to, + .item-grid-slot.is-dragging, .reorderable-row.is-dragging, .drag-handle:active { transform: none; diff --git a/frontend/src/views/ItemEdit.vue b/frontend/src/views/ItemEdit.vue index c5e451e..464e8f5 100644 --- a/frontend/src/views/ItemEdit.vue +++ b/frontend/src/views/ItemEdit.vue @@ -65,6 +65,8 @@ const itemCreateDefaultsStorageKey = 'pokopia_item_create_defaults'; const routeId = computed(() => (typeof route.params.id === 'string' ? route.params.id : '')); const isEditing = computed(() => routeId.value !== ''); const isEventCreate = computed(() => route.name === 'event-item-new'); +const insertBeforeItemId = computed(() => queryItemId(route.query.insertBeforeItemId)); +const insertAfterItemId = computed(() => queryItemId(route.query.insertAfterItemId)); const pageTitle = computed(() => isEditing.value ? t('pages.items.editTitle', { name: itemForm.value.name || t('pages.items.fallbackName') }) @@ -82,6 +84,12 @@ function toIds(values: string[]): number[] { return values.map(Number).filter((item) => Number.isInteger(item) && item > 0); } +function queryItemId(value: unknown): number | null { + const rawValue = Array.isArray(value) ? value[0] : value; + const id = Number(rawValue); + return Number.isInteger(id) && id > 0 ? id : null; +} + function errorText(error: unknown, fallback: string) { return error instanceof Error && error.message ? error.message : fallback; } @@ -264,6 +272,12 @@ async function saveItem() { tagIds: toIds(itemForm.value.tagIds), imagePath: itemForm.value.imagePath }; + if (!isEditing.value && insertBeforeItemId.value !== null) { + payload.insertBeforeItemId = insertBeforeItemId.value; + } + if (!isEditing.value && insertAfterItemId.value !== null) { + payload.insertAfterItemId = insertAfterItemId.value; + } const saved = isEditing.value ? await api.updateItem(routeId.value, payload) : await api.createItem(payload); await router.push(`/items/${saved.id}`); } catch (error) { diff --git a/frontend/src/views/ItemsList.vue b/frontend/src/views/ItemsList.vue index a93cc44..e356e65 100644 --- a/frontend/src/views/ItemsList.vue +++ b/frontend/src/views/ItemsList.vue @@ -2,14 +2,14 @@ import { Icon } from '@iconify/vue'; import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'; import { useI18n } from 'vue-i18n'; -import { useRoute } from 'vue-router'; +import { useRoute, useRouter } from 'vue-router'; import EntityCard from '../components/EntityCard.vue'; import FilterPanel from '../components/FilterPanel.vue'; import PageHeader from '../components/PageHeader.vue'; import Skeleton from '../components/Skeleton.vue'; import Tabs, { type TabOption } from '../components/Tabs.vue'; import TagsSelect from '../components/TagsSelect.vue'; -import { iconAdd, iconChevronDown, iconItem } from '../icons'; +import { iconAdd, iconChevronDown, iconChevronUp, iconItem } from '../icons'; import { api, getAuthToken, type AuthUser, type Item, type Options } from '../services/api'; import ItemEdit from './ItemEdit.vue'; @@ -19,16 +19,30 @@ const props = defineProps<{ const options = ref(null); const route = useRoute(); +const router = useRouter(); const { t } = useI18n(); const items = ref([]); const currentUser = ref(null); const loading = ref(true); +const ordering = ref(false); const search = ref(''); const categoryId = ref(''); const usageId = ref(''); const tagIds = ref([]); const createDefaultsMenu = ref(null); const createDefaultsOpen = ref(false); +const itemContextMenu = ref<{ + item: Item; + x: number; + y: number; +} | null>(null); +const itemContextMenuRef = ref(null); +const draggingItemId = ref(null); +const dropTargetItemId = ref(null); +const dropInsertAfter = ref(false); +const suppressNextItemClick = ref(false); +const dragSourceItems = ref([]); +const dropCommitted = ref(false); type ItemCreateDefaults = { categoryId: string; @@ -59,6 +73,16 @@ const pageTitle = computed(() => (props.eventOnly ? t('pages.eventItems.title') const pageSubtitle = computed(() => (props.eventOnly ? t('pages.eventItems.subtitle') : t('pages.items.subtitle'))); const pageKicker = computed(() => (props.eventOnly ? t('pages.eventItems.kicker') : t('pages.items.kicker'))); const createTarget = computed(() => (props.eventOnly ? '/event-items/new' : '/items/new')); +const isAllView = computed(() => !props.eventOnly && categoryId.value === ''); +const hasActiveFilters = computed( + () => search.value.trim() !== '' || usageId.value !== '' || tagIds.value.length > 0 || categoryId.value !== '' +); +const itemSortingAllowed = computed( + () => isAllView.value && !hasActiveFilters.value && currentUser.value?.permissions.includes('items.order') === true +); +const itemInsertionAllowed = computed( + () => itemSortingAllowed.value && currentUser.value?.permissions.includes('items.create') === true +); const categoryTabs = computed(() => [ { value: '', label: t('common.all') }, @@ -88,6 +112,57 @@ function itemCardImage(item: Item) { return item.image ? { src: item.image.url, alt: t('media.imageAlt', { name: item.name }) } : undefined; } +function sameItem(first: number, second: number): boolean { + return first === second; +} + +function reorderedItems(currentItems: Item[], draggedId: number, targetId: number, insertAfter: boolean): Item[] { + if (sameItem(draggedId, targetId)) { + return currentItems; + } + + const draggedItem = currentItems.find((item) => sameItem(item.id, draggedId)); + if (!draggedItem) { + return currentItems; + } + + const nextItems = currentItems.filter((item) => !sameItem(item.id, draggedId)); + const targetIndex = nextItems.findIndex((item) => sameItem(item.id, targetId)); + if (targetIndex < 0) { + return currentItems; + } + + nextItems.splice(targetIndex + (insertAfter ? 1 : 0), 0, draggedItem); + return nextItems; +} + +function hasOrderChanged(currentItems: Item[], nextItems: Item[]): boolean { + return currentItems.length !== nextItems.length || currentItems.some((item, index) => !sameItem(item.id, nextItems[index]?.id ?? -1)); +} + +function clampMenuPosition(x: number, y: number) { + const width = 216; + const height = 104; + return { + x: Math.max(16, Math.min(x, window.innerWidth - width - 16)), + y: Math.max(16, Math.min(y, window.innerHeight - height - 16)) + }; +} + +function menuPositionForEvent(event: MouseEvent | KeyboardEvent) { + if (event instanceof MouseEvent) { + return clampMenuPosition(event.clientX, event.clientY); + } + + const target = event.currentTarget instanceof HTMLElement ? event.currentTarget : null; + if (target) { + const rect = target.getBoundingClientRect(); + return clampMenuPosition(rect.left, rect.bottom + 6); + } + + return clampMenuPosition(window.innerWidth / 2, window.innerHeight / 2); +} + function readItemCreateDefaults(): ItemCreateDefaults { if (typeof sessionStorage === 'undefined') { return emptyItemCreateDefaults(); @@ -171,14 +246,20 @@ function clearItemCreateDefaults() { } function onCreateDefaultsDocumentPointerDown(event: PointerEvent) { - if (createDefaultsMenu.value && !createDefaultsMenu.value.contains(event.target as Node)) { + const target = event.target as Node | null; + if (createDefaultsMenu.value && target && !createDefaultsMenu.value.contains(target)) { closeCreateDefaultsMenu(); } + + if (itemContextMenu.value && itemContextMenuRef.value && target && !itemContextMenuRef.value.contains(target)) { + closeItemContextMenu(); + } } function onCreateDefaultsKeydown(event: KeyboardEvent) { if (event.key === 'Escape') { closeCreateDefaultsMenu(); + closeItemContextMenu(); } if (event.key === 'ContextMenu' || (event.shiftKey && event.key === 'F10')) { @@ -186,6 +267,151 @@ function onCreateDefaultsKeydown(event: KeyboardEvent) { } } +function onDocumentKeydown(event: KeyboardEvent) { + if (event.key === 'Escape') { + closeCreateDefaultsMenu(); + closeItemContextMenu(); + } +} + +function closeItemContextMenu() { + itemContextMenu.value = null; +} + +function openItemContextMenu(item: Item, event: MouseEvent | KeyboardEvent) { + if (!itemInsertionAllowed.value) { + return; + } + + event.preventDefault(); + closeCreateDefaultsMenu(); + + const { x, y } = menuPositionForEvent(event); + itemContextMenu.value = { item, x, y }; +} + +function startItemInsert(position: 'before' | 'after') { + if (!itemContextMenu.value) { + return; + } + + const queryKey = position === 'before' ? 'insertBeforeItemId' : 'insertAfterItemId'; + void router.push({ + path: createTarget.value, + query: { [queryKey]: String(itemContextMenu.value.item.id) } + }); + closeItemContextMenu(); +} + +function clearItemDragState() { + draggingItemId.value = null; + dropTargetItemId.value = null; + dropInsertAfter.value = false; + dragSourceItems.value = []; + dropCommitted.value = false; +} + +function startItemDrag(item: Item, event: DragEvent) { + if (!itemSortingAllowed.value || ordering.value) { + return; + } + + draggingItemId.value = item.id; + dropTargetItemId.value = null; + dropInsertAfter.value = false; + suppressNextItemClick.value = false; + dragSourceItems.value = [...items.value]; + dropCommitted.value = false; + event.dataTransfer?.setData('text/plain', String(item.id)); + if (event.dataTransfer) { + event.dataTransfer.effectAllowed = 'move'; + event.dataTransfer.dropEffect = 'move'; + } +} + +function endItemDrag() { + if (draggingItemId.value !== null && !dropCommitted.value && dragSourceItems.value.length) { + items.value = [...dragSourceItems.value]; + } + + if (draggingItemId.value !== null) { + suppressNextItemClick.value = true; + } + + clearItemDragState(); +} + +function previewItemDrop(targetItem: Item, event: DragEvent) { + if (!itemSortingAllowed.value || draggingItemId.value === null || ordering.value) { + return; + } + + const draggedId = draggingItemId.value; + if (sameItem(draggedId, targetItem.id)) { + dropTargetItemId.value = null; + dropInsertAfter.value = false; + return; + } + + const element = event.currentTarget instanceof HTMLElement ? event.currentTarget : null; + const insertAfter = element ? event.clientY > element.getBoundingClientRect().top + element.getBoundingClientRect().height / 2 : false; + dropTargetItemId.value = targetItem.id; + dropInsertAfter.value = insertAfter; + if (event.dataTransfer) { + event.dataTransfer.dropEffect = 'move'; + } + + const nextItems = reorderedItems(items.value, draggedId, targetItem.id, insertAfter); + if (hasOrderChanged(items.value, nextItems)) { + items.value = nextItems; + } +} + +async function dropItem(targetItem: Item, event: DragEvent) { + if (!itemSortingAllowed.value || draggingItemId.value === null || ordering.value) { + clearItemDragState(); + return; + } + + previewItemDrop(targetItem, event); + const nextItems = [...items.value]; + const previousItems = dragSourceItems.value.length ? [...dragSourceItems.value] : nextItems; + dropCommitted.value = true; + clearItemDragState(); + + if (!hasOrderChanged(previousItems, nextItems)) { + return; + } + + items.value = nextItems; + ordering.value = true; + suppressNextItemClick.value = true; + try { + await api.reorderItems(nextItems.map((item) => item.id)); + items.value = await api.items(itemQuery.value); + } catch { + items.value = previousItems; + } finally { + ordering.value = false; + } +} + +function handleItemKeydown(item: Item, event: KeyboardEvent) { + if (event.key === 'ContextMenu' || (event.shiftKey && event.key === 'F10')) { + openItemContextMenu(item, event); + } +} + +function handleItemClick(event: MouseEvent) { + if (!suppressNextItemClick.value) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + suppressNextItemClick.value = false; +} + async function loadItems() { loading.value = true; items.value = await api.items(itemQuery.value); @@ -194,6 +420,7 @@ async function loadItems() { onMounted(async () => { document.addEventListener('pointerdown', onCreateDefaultsDocumentPointerDown); + document.addEventListener('keydown', onDocumentKeydown); if (getAuthToken()) { try { currentUser.value = (await api.me()).user; @@ -208,11 +435,21 @@ onMounted(async () => { onBeforeUnmount(() => { document.removeEventListener('pointerdown', onCreateDefaultsDocumentPointerDown); + document.removeEventListener('keydown', onDocumentKeydown); }); watch(itemQuery, loadItems); watch(itemCreateDefaults, persistItemCreateDefaults, { deep: true }); -watch(showEditor, closeCreateDefaultsMenu); +watch(showEditor, () => { + closeCreateDefaultsMenu(); + closeItemContextMenu(); +}); +watch(itemSortingAllowed, (allowed) => { + if (!allowed) { + clearItemDragState(); + closeItemContextMenu(); + } +});