Feat: 拖拽移动照片手动调整

This commit is contained in:
奇趣保罗 2026-02-02 11:18:52 +08:00
parent 4419f63e64
commit 1e17531e6a
2 changed files with 143 additions and 16 deletions

View File

@ -4,7 +4,6 @@ import { snapdom } from "@zumer/snapdom";
import { Camera, Palette, Aperture, Zap, User, Lightbulb, Users2, MapPin, WandSparkles, Settings, BadgeDollarSign } from "lucide-vue-next"; import { Camera, Palette, Aperture, Zap, User, Lightbulb, Users2, MapPin, WandSparkles, Settings, BadgeDollarSign } from "lucide-vue-next";
import { IconQQ, IconTikTok, IconWeChat, IconXHS } from "./components/common/icons"; import { IconQQ, IconTikTok, IconWeChat, IconXHS } from "./components/common/icons";
import SettingsModal from "./components/biz/settings-modal.vue"; import SettingsModal from "./components/biz/settings-modal.vue";
import { usePhotoColumns } from "./composables/use-photo-columns";
interface MediaItem { interface MediaItem {
id: number; id: number;
@ -19,6 +18,8 @@ interface MediaItem {
} }
const photos = ref<MediaItem[]>([]); const photos = ref<MediaItem[]>([]);
const photoOrder = ref<MediaItem[]>([]);
const columns = ref<MediaItem[][]>([]);
const loading = ref(true); const loading = ref(true);
const posterRef = ref<HTMLElement>(); const posterRef = ref<HTMLElement>();
const isCapturing = ref(false); const isCapturing = ref(false);
@ -28,8 +29,35 @@ const hiddenPhotos = ref<number[]>([]);
const columnCount = ref(4); const columnCount = ref(4);
const showPrice = ref(true); const showPrice = ref(true);
// 使 hooks let defaultColumnsCache: MediaItem[][] = [];
const columns = usePhotoColumns(photos, columnCount);
const cloneColumns = (columnsValue: MediaItem[][]) =>
columnsValue.map((column) => column.slice());
const buildColumns = (list: MediaItem[], count: number) => {
const result: MediaItem[][] = Array.from({ length: count }, () => []);
list.forEach((photo, index) => {
const columnIndex = index % count;
result[columnIndex].push(photo);
});
return result;
};
const flattenColumns = (columnsValue: MediaItem[][]) => {
const result: MediaItem[] = [];
const maxRows = Math.max(0, ...columnsValue.map((column) => column.length));
for (let row = 0; row < maxRows; row += 1) {
for (let col = 0; col < columnsValue.length; col += 1) {
const photo = columnsValue[col][row];
if (photo) result.push(photo);
}
}
return result;
};
const getInitialColumns = () => cloneColumns(defaultColumnsCache);
// grid // grid
const gridColsClass = computed(() => { const gridColsClass = computed(() => {
@ -46,7 +74,7 @@ const fetchPhotos = async () => {
const response = await fetch(`https://paul.ren/api/media?cate=9&starred=1&size=${photoSize.value}`); const response = await fetch(`https://paul.ren/api/media?cate=9&starred=1&size=${photoSize.value}`);
const data = await response.json(); const data = await response.json();
// URL // URL
photos.value = data.data.map((photo: MediaItem) => { const nextPhotos = data.data.map((photo: MediaItem) => {
const parsedMeta = JSON.parse(photo.meta); const parsedMeta = JSON.parse(photo.meta);
return { return {
@ -57,6 +85,11 @@ const fetchPhotos = async () => {
height: parsedMeta.COMPUTED.Height, height: parsedMeta.COMPUTED.Height,
}; };
}); });
photos.value = nextPhotos;
photoOrder.value = nextPhotos.slice();
const nextColumns = buildColumns(nextPhotos, columnCount.value);
columns.value = cloneColumns(nextColumns);
defaultColumnsCache = cloneColumns(nextColumns);
loading.value = false; loading.value = false;
} catch (error) { } catch (error) {
console.error("Failed to fetch photos:", error); console.error("Failed to fetch photos:", error);
@ -72,6 +105,12 @@ const handlePhotoToggle = (id: number, hidden: boolean) => {
} }
}; };
const handleColumnsUpdate = (nextColumns: MediaItem[][]) => {
columns.value = cloneColumns(nextColumns);
photoOrder.value = flattenColumns(nextColumns);
photos.value = photoOrder.value.slice();
};
const handleSizeConfirm = (newSize: number) => { const handleSizeConfirm = (newSize: number) => {
if (photoSize.value !== newSize) { if (photoSize.value !== newSize) {
photoSize.value = newSize; photoSize.value = newSize;
@ -82,6 +121,7 @@ const handleSizeConfirm = (newSize: number) => {
const handleColumnCountChange = (newCount: number) => { const handleColumnCountChange = (newCount: number) => {
columnCount.value = newCount; columnCount.value = newCount;
columns.value = buildColumns(photoOrder.value, newCount);
}; };
const handleShowPriceChange = (newValue: boolean) => { const handleShowPriceChange = (newValue: boolean) => {
@ -278,12 +318,14 @@ onMounted(() => {
:initial-column-count="columnCount" :initial-column-count="columnCount"
:initial-show-price="showPrice" :initial-show-price="showPrice"
:hidden-photos="hiddenPhotos" :hidden-photos="hiddenPhotos"
:photos="photos" :columns="columns"
:initial-columns="getInitialColumns()"
@close="showSettings = false" @close="showSettings = false"
@update:size="handleSizeConfirm" @update:size="handleSizeConfirm"
@update:column-count="handleColumnCountChange" @update:column-count="handleColumnCountChange"
@update:show-price="handleShowPriceChange" @update:show-price="handleShowPriceChange"
@toggle-photo="handlePhotoToggle" @toggle-photo="handlePhotoToggle"
@update:columns="handleColumnsUpdate"
/> />
</div> </div>
</template> </template>

View File

@ -2,7 +2,6 @@
import { ref, computed, watch } from "vue"; import { ref, computed, watch } from "vue";
import BaseDrawer from "../ui/drawer.vue"; import BaseDrawer from "../ui/drawer.vue";
import { Check } from "lucide-vue-next"; import { Check } from "lucide-vue-next";
import { usePhotoColumns } from "../../composables/use-photo-columns";
interface MediaItem { interface MediaItem {
id: number; id: number;
@ -22,7 +21,8 @@ const props = defineProps<{
initialColumnCount: number; initialColumnCount: number;
initialShowPrice: boolean; initialShowPrice: boolean;
hiddenPhotos: number[]; hiddenPhotos: number[];
photos: MediaItem[]; columns: MediaItem[][];
initialColumns: MediaItem[][];
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
@ -31,11 +31,14 @@ const emit = defineEmits<{
(e: "update:column-count", value: number): void; (e: "update:column-count", value: number): void;
(e: "update:show-price", value: boolean): void; (e: "update:show-price", value: boolean): void;
(e: "toggle-photo", id: number, hidden: boolean): void; (e: "toggle-photo", id: number, hidden: boolean): void;
(e: "update:columns", value: MediaItem[][]): void;
}>(); }>();
const tempSize = ref(props.initialSize); const tempSize = ref(props.initialSize);
const tempColumnCount = ref(props.initialColumnCount); const tempColumnCount = ref(props.initialColumnCount);
const tempShowPrice = ref(props.initialShowPrice); const tempShowPrice = ref(props.initialShowPrice);
const tempColumns = ref<MediaItem[][]>([]);
const draggingPhotoId = ref<number | null>(null);
watch( watch(
() => props.initialSize, () => props.initialSize,
@ -58,9 +61,16 @@ watch(
} }
); );
// 使 hooks const cloneColumns = (columnsValue: MediaItem[][]) =>
const photosRef = computed(() => props.photos); columnsValue.map((column) => column.slice());
const columns = usePhotoColumns(photosRef, tempColumnCount);
watch(
() => props.columns,
(value) => {
tempColumns.value = cloneColumns(value);
},
{ immediate: true }
);
// grid // grid
const gridColsClass = computed(() => { const gridColsClass = computed(() => {
@ -93,6 +103,61 @@ const isPhotoHidden = (id: number) => props.hiddenPhotos.includes(id);
const togglePhoto = (id: number) => { const togglePhoto = (id: number) => {
emit("toggle-photo", id, !isPhotoHidden(id)); emit("toggle-photo", id, !isPhotoHidden(id));
}; };
const emitColumns = (nextColumns: MediaItem[][]) => {
tempColumns.value = cloneColumns(nextColumns);
emit("update:columns", cloneColumns(nextColumns));
};
const handleDragStart = (photoId: number, event: DragEvent) => {
draggingPhotoId.value = photoId;
event.dataTransfer?.setData("text/plain", String(photoId));
event.dataTransfer?.setDragImage?.((event.target as HTMLElement) ?? new Image(), 10, 10);
};
const handleDragEnd = () => {
draggingPhotoId.value = null;
};
const takeDraggedPhoto = (columnsValue: MediaItem[][]) => {
let draggedPhoto: MediaItem | undefined;
columnsValue.forEach((column) => {
const index = column.findIndex((photo) => photo.id === draggingPhotoId.value);
if (index !== -1) {
draggedPhoto = column.splice(index, 1)[0];
}
});
return draggedPhoto;
};
const movePhotoToColumn = (targetColumnIndex: number) => {
if (draggingPhotoId.value === null) return;
const columnsSnapshot = cloneColumns(tempColumns.value);
const draggedPhoto = takeDraggedPhoto(columnsSnapshot);
if (!draggedPhoto) return;
columnsSnapshot[targetColumnIndex]?.push(draggedPhoto);
emitColumns(columnsSnapshot);
};
const movePhotoBeforeTarget = (targetColumnIndex: number, targetIndex: number) => {
if (draggingPhotoId.value === null) return;
const columnsSnapshot = cloneColumns(tempColumns.value);
const draggedPhoto = takeDraggedPhoto(columnsSnapshot);
if (!draggedPhoto) return;
const targetColumn = columnsSnapshot[targetColumnIndex];
if (!targetColumn) return;
const insertIndex = Math.min(Math.max(targetIndex, 0), targetColumn.length);
targetColumn.splice(insertIndex, 0, draggedPhoto);
emitColumns(columnsSnapshot);
};
const resetPhotoOrder = () => {
if (!props.initialColumns.length) return;
emitColumns(cloneColumns(props.initialColumns));
};
</script> </script>
<template> <template>
@ -167,19 +232,39 @@ const togglePhoto = (id: number) => {
</section> </section>
<section class="space-y-3"> <section class="space-y-3">
<header> <header class="flex items-center justify-between gap-4">
<p class="text-xs font-semibold uppercase tracking-wide text-gray-400">内容</p> <div>
<p class="text-base font-medium text-gray-900">照片显示控制</p> <p class="text-xs font-semibold uppercase tracking-wide text-gray-400">内容</p>
<p class="text-base font-medium text-gray-900">照片显示控制</p>
</div>
<button
type="button"
class="text-xs text-gray-500 hover:text-gray-700 transition-colors"
@click="resetPhotoOrder"
>
恢复默认位置
</button>
</header> </header>
<div :class="['grid', 'gap-2', gridColsClass]"> <div :class="['grid', 'gap-2', gridColsClass]">
<div class="space-y-2" v-for="(column, index) in columns" :key="index"> <div
class="space-y-2"
v-for="(column, index) in tempColumns"
:key="index"
@dragover.prevent
@drop.self.prevent="movePhotoToColumn(index)"
>
<button <button
v-for="photo in column" v-for="(photo, photoIndex) in column"
:key="photo.id" :key="photo.id"
@click="togglePhoto(photo.id)" @click="togglePhoto(photo.id)"
draggable="true"
@dragstart="handleDragStart(photo.id, $event)"
@dragend="handleDragEnd"
@dragover.prevent
@drop.stop.prevent="movePhotoBeforeTarget(index, photoIndex)"
class="relative text-xs rounded-xl transition-colors" class="relative text-xs rounded-xl transition-colors"
:class="[ :class="[
'w-full p-1 cursor-pointer', 'w-full p-1 cursor-move',
isPhotoHidden(photo.id) ? 'bg-gray-50 text-gray-400 opacity-60' : 'bg-yellow-300 text-gray-700', isPhotoHidden(photo.id) ? 'bg-gray-50 text-gray-400 opacity-60' : 'bg-yellow-300 text-gray-700',
]" ]"
> >