Feat: 拖拽移动照片手动调整
This commit is contained in:
parent
4419f63e64
commit
1e17531e6a
52
src/App.vue
52
src/App.vue
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue