Feat: Add Settings Modal

This commit is contained in:
奇趣保罗 2026-01-23 22:19:37 +08:00
parent 240fdedec7
commit 1ea027e4f1
4 changed files with 405 additions and 26 deletions

View File

@ -1,8 +1,10 @@
<script setup lang="ts">
import { ref, onMounted, nextTick } from "vue";
import { ref, computed, onMounted } from "vue";
import { snapdom } from "@zumer/snapdom";
import { Camera, Palette, Aperture, Zap, User, Lightbulb, HeartHandshake, Users2, MapPin, WandSparkles } from "lucide-vue-next";
import { Camera, Palette, Aperture, Zap, User, Lightbulb, Users2, MapPin, WandSparkles, Settings, BadgeDollarSign } from "lucide-vue-next";
import { IconQQ, IconWeChat, IconXHS } from "./components/common/icons";
import SettingsModal from "./components/biz/settings-modal.vue";
import { usePhotoColumns } from "./composables/use-photo-columns";
interface MediaItem {
id: number;
@ -18,13 +20,30 @@ interface MediaItem {
const photos = ref<MediaItem[]>([]);
const loading = ref(true);
const columns = ref<MediaItem[][]>([[], [], []]);
const posterRef = ref<HTMLElement>();
const isCapturing = ref(false);
const showSettings = ref(false);
const photoSize = ref(30);
const hiddenPhotos = ref<number[]>([]);
const columnCount = ref(4);
const showPrice = ref(true);
// 使 hooks
const columns = usePhotoColumns(photos, columnCount);
// grid
const gridColsClass = computed(() => {
const colsMap: Record<number, string> = {
3: 'grid-cols-3',
4: 'grid-cols-4',
5: 'grid-cols-5',
};
return colsMap[columnCount.value] || 'grid-cols-4';
});
const fetchPhotos = async () => {
try {
const response = await fetch("https://paul.ren/api/media?cate=9&starred=1&size=30");
const response = await fetch(`https://paul.ren/api/media?cate=9&starred=1&size=${photoSize.value}`);
const data = await response.json();
// URL
photos.value = data.data.map((photo: MediaItem) => {
@ -39,22 +58,34 @@ const fetchPhotos = async () => {
};
});
loading.value = false;
await nextTick();
arrangePhotos();
} catch (error) {
console.error("Failed to fetch photos:", error);
loading.value = false;
}
};
const arrangePhotos = () => {
columns.value = [[], [], []];
const handlePhotoToggle = (id: number, hidden: boolean) => {
if (hidden) {
hiddenPhotos.value.push(id);
} else {
hiddenPhotos.value = hiddenPhotos.value.filter((photoId) => photoId !== id);
}
};
photos.value.forEach((photo, index) => {
const columnIndex = index % 3;
columns.value[columnIndex].push(photo);
});
const handleSizeConfirm = (newSize: number) => {
if (photoSize.value !== newSize) {
photoSize.value = newSize;
loading.value = true;
fetchPhotos();
}
};
const handleColumnCountChange = (newCount: number) => {
columnCount.value = newCount;
};
const handleShowPriceChange = (newValue: boolean) => {
showPrice.value = newValue;
};
const formatDate = (dateStr: string) => {
@ -69,7 +100,7 @@ const formatDate = (dateStr: string) => {
const getImageUrl = (url: string) => {
// 使
if (import.meta.env.DEV) {
return url.replace("https://legacy.paul.ren", "/proxy-images");
return url.replace("https://legacy.paul.ren", "https://photo.paul.xin");
}
return url.replace("https://legacy.paul.ren", "/");
@ -107,6 +138,13 @@ onMounted(() => {
<template>
<div class="min-h-screen bg-gradient-to-br from-gray-100 to-gray-200 min-[769px]:px-4 min-[769px]:py-8">
<div class="fixed top-4 right-4 z-50 flex gap-2">
<button
class="cursor-pointer bg-white/90 backdrop-blur-sm text-gray-700 px-4 py-2 rounded-full shadow-lg hover:bg-white transition-all duration-300 flex items-center gap-2 hover:scale-105"
@click="showSettings = true"
>
<Settings :size="20" />
<span>设置</span>
</button>
<button
class="cursor-pointer bg-white/90 backdrop-blur-sm text-gray-700 px-4 py-2 rounded-full shadow-lg hover:bg-white transition-all duration-300 flex items-center gap-2 hover:scale-105"
:disabled="isCapturing"
@ -160,10 +198,6 @@ onMounted(() => {
<Users2 class="opacity-60" />
<span class="flex-1">可动作指导建议自带动作</span>
</li>
<li class="flex items-center gap-2">
<HeartHandshake class="opacity-60" />
<span class="flex-1">审题材妆造互勉</span>
</li>
</ul>
<ul>
<li class="flex items-center gap-2">
@ -187,7 +221,11 @@ onMounted(() => {
<div class="text-gray-200 text-xl leading-[1.8]">
<p class="flex items-center gap-2">
<WandSparkles class="shrink-0 opacity-60" />
<span class="flex-1">互勉角色: 原神崩坏星穹铁道绝区零鸣潮蔚蓝档案碧蓝航线等</span>
<span class="flex-1">互勉角色: 原神崩铁绝区零鸣潮蔚蓝档案碧蓝航线等</span>
</p>
<p v-if="showPrice" class="flex items-center gap-2">
<BadgeDollarSign class="opacity-60" />
<span class="flex-1">接单价格: 场照单人 10 1 双人 15 1 组合有优惠详情可咨询</span>
</p>
</div>
</section>
@ -197,15 +235,19 @@ onMounted(() => {
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-yellow-400"></div>
</main>
<main v-else>
<section class="grid grid-cols-3 gap-2 mb-8">
<section :class="['grid', 'gap-2', 'mb-8', gridColsClass]">
<div v-for="(column, colIndex) in columns" :key="colIndex" class="space-y-2">
<div v-for="photo in column" :key="photo.id" class="relative rounded-lg overflow-hidden group bg-gray-900">
<img :src="photo.url" :alt="photo.title" class="w-full h-auto block rounded-lg" :width="photo.width" :height="photo.height" />
<div class="absolute bg-gradient-to-t from-black to-transparent bottom-0 left-0 right-0 p-4 pt-8 text-white transition-opacity duration-300 opacity-0 group-hover:opacity-100">
<h3 class="font-semibold text-white mb-1 text-lg">{{ photo.title }}</h3>
<p class="text-xs opacity-60">{{ formatDate(photo.take_time) }}</p>
<template v-for="photo in column" :key="photo.id">
<div
v-if="!hiddenPhotos.includes(photo.id)"
class="relative rounded-lg overflow-hidden group bg-gray-900">
<img :src="photo.url" :alt="photo.title" class="w-full h-auto block rounded-lg" :width="photo.width" :height="photo.height" />
<div class="absolute bg-gradient-to-t from-black to-transparent bottom-0 left-0 right-0 p-4 pt-8 text-white transition-opacity duration-300 opacity-0 group-hover:opacity-100">
<h3 class="font-semibold text-white mb-1 text-lg">{{ photo.title }}</h3>
<p class="text-xs opacity-60">{{ formatDate(photo.take_time) }}</p>
</div>
</div>
</div>
</template>
</div>
</section>
<section class="grid gap-4 grid-cols-3 text-gray-200 text-lg">
@ -226,5 +268,19 @@ onMounted(() => {
</div>
</div>
</div>
<SettingsModal
:show="showSettings"
:initial-size="photoSize"
:initial-column-count="columnCount"
:initial-show-price="showPrice"
:hidden-photos="hiddenPhotos"
:photos="photos"
@close="showSettings = false"
@update:size="handleSizeConfirm"
@update:column-count="handleColumnCountChange"
@update:show-price="handleShowPriceChange"
@toggle-photo="handlePhotoToggle"
/>
</div>
</template>

View File

@ -0,0 +1,200 @@
<script setup lang="ts">
import { ref, computed, watch } from "vue";
import BaseDrawer from "../ui/drawer.vue";
import { Check } from "lucide-vue-next";
import { usePhotoColumns } from "../../composables/use-photo-columns";
interface MediaItem {
id: number;
title: string;
content: string;
url: string;
thumb_url: string;
meta: string;
take_time: string;
width?: number;
height?: number;
}
const props = defineProps<{
show: boolean;
initialSize: number;
initialColumnCount: number;
initialShowPrice: boolean;
hiddenPhotos: number[];
photos: MediaItem[];
}>();
const emit = defineEmits<{
(e: "close"): void;
(e: "update:size", value: number): void;
(e: "update:column-count", value: number): void;
(e: "update:show-price", value: boolean): void;
(e: "toggle-photo", id: number, hidden: boolean): void;
}>();
const tempSize = ref(props.initialSize);
const tempColumnCount = ref(props.initialColumnCount);
const tempShowPrice = ref(props.initialShowPrice);
watch(
() => props.initialSize,
(value) => {
tempSize.value = value;
}
);
watch(
() => props.initialColumnCount,
(value) => {
tempColumnCount.value = value;
}
);
watch(
() => props.initialShowPrice,
(value) => {
tempShowPrice.value = value;
}
);
// 使 hooks
const photosRef = computed(() => props.photos);
const columns = usePhotoColumns(photosRef, tempColumnCount);
// grid
const gridColsClass = computed(() => {
const colsMap: Record<number, string> = {
3: 'grid-cols-3',
4: 'grid-cols-4',
5: 'grid-cols-5',
};
return colsMap[tempColumnCount.value] || 'grid-cols-4';
});
const handleClose = () => {
emit("close");
};
const handleSizeCommit = () => {
emit("update:size", tempSize.value);
};
const handleColumnCountCommit = () => {
emit("update:column-count", tempColumnCount.value);
};
const handleShowPriceChange = () => {
emit("update:show-price", tempShowPrice.value);
};
const isPhotoHidden = (id: number) => props.hiddenPhotos.includes(id);
const togglePhoto = (id: number) => {
emit("toggle-photo", id, !isPhotoHidden(id));
};
</script>
<template>
<BaseDrawer :show="show" title="桌面预览设置" @close="handleClose">
<div class="space-y-8">
<section class="space-y-3">
<header class="flex items-center justify-between">
<div>
<p class="text-xs font-semibold uppercase tracking-wide text-gray-400">显示数量</p>
<p class="text-base font-medium text-gray-900">最大可见照片</p>
</div>
<span class="text-xl font-semibold text-gray-900">{{ tempSize }} </span>
</header>
<div class="space-y-3">
<input
type="range"
v-model="tempSize"
min="10"
max="50"
step="1"
class="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-yellow-400"
@change="handleSizeCommit"
/>
<div class="flex justify-between text-xs text-gray-400">
<span>10 </span>
<span>50 </span>
</div>
</div>
</section>
<section class="space-y-3">
<header class="flex items-center justify-between">
<div>
<p class="text-xs font-semibold uppercase tracking-wide text-gray-400">布局设置</p>
<p class="text-base font-medium text-gray-900">照片列数</p>
</div>
<span class="text-xl font-semibold text-gray-900">{{ tempColumnCount }} </span>
</header>
<div class="space-y-3">
<input
type="range"
v-model="tempColumnCount"
min="3"
max="5"
step="1"
class="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-yellow-400"
@change="handleColumnCountCommit"
/>
<div class="flex justify-between text-xs text-gray-400">
<span>3 </span>
<span>5 </span>
</div>
</div>
</section>
<section class="space-y-3">
<header class="flex items-center justify-between">
<div>
<p class="text-xs font-semibold uppercase tracking-wide text-gray-400">内容显示</p>
<p class="text-base font-medium text-gray-900">显示接单价格</p>
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
v-model="tempShowPrice"
@change="handleShowPriceChange"
class="sr-only peer"
/>
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-yellow-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-yellow-400"></div>
</label>
</header>
</section>
<section class="space-y-3">
<header>
<p class="text-xs font-semibold uppercase tracking-wide text-gray-400">内容</p>
<p class="text-base font-medium text-gray-900">照片显示控制</p>
</header>
<div :class="['grid', 'gap-2', gridColsClass]">
<div class="space-y-2" v-for="(column, index) in columns" :key="index">
<button
v-for="photo in column"
:key="photo.id"
@click="togglePhoto(photo.id)"
class="relative text-xs rounded-xl transition-colors"
:class="[
'w-full p-1 cursor-pointer',
isPhotoHidden(photo.id) ? 'bg-gray-50 text-gray-400 opacity-60' : 'bg-yellow-300 text-gray-700',
]"
>
<span
v-if="!isPhotoHidden(photo.id)"
class="absolute top-2 left-2 bg-yellow-400 rounded-full p-1"
>
<Check class="size-3" />
</span>
<img :src="photo.url" :alt="photo.title" class="w-full h-auto block rounded-lg" :width="photo.width" :height="photo.height" />
<!-- <span class="mt-1 px-2 block truncate">{{ photo.title }}</span> -->
</button>
</div>
</div>
</section>
</div>
</BaseDrawer>
</template>

View File

@ -0,0 +1,85 @@
<!-- eslint-disable vue/multi-word-component-names -->
<script setup lang="ts">
import { onMounted, onUnmounted } from "vue";
defineProps<{
show: boolean;
title?: string;
}>();
const emit = defineEmits<{
(e: "close"): void;
}>();
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape") {
emit("close");
}
};
onMounted(() => {
document.addEventListener("keydown", handleEscape);
});
onUnmounted(() => {
document.removeEventListener("keydown", handleEscape);
});
</script>
<template>
<Teleport to="body">
<Transition name="drawer">
<div v-if="show" class="fixed inset-0 z-50 flex">
<div class="drawer-panel w-full max-w-md h-full bg-white shadow-xl border-r border-gray-200 flex flex-col">
<div class="flex items-center justify-between px-5 py-4 border-b border-gray-100">
<div>
<p class="text-sm uppercase tracking-wide text-gray-400">设置</p>
<h3 class="text-lg font-semibold text-gray-900">{{ title }}</h3>
</div>
<button @click="emit('close')" class="p-2 text-gray-400 hover:text-gray-600 rounded-full transition-colors" aria-label="关闭设置抽屉">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="flex-1 overflow-y-auto p-5">
<slot />
</div>
</div>
<div class="drawer-overlay flex-1" @click="emit('close')" />
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.drawer-enter-active,
.drawer-leave-active {
transition: opacity 0.3s ease;
}
.drawer-enter-from,
.drawer-leave-to {
opacity: 0;
}
.drawer-enter-active .drawer-panel,
.drawer-leave-active .drawer-panel {
transition: transform 0.3s ease;
}
.drawer-enter-from .drawer-panel,
.drawer-leave-to .drawer-panel {
transform: translateX(-100%);
}
.drawer-enter-active .drawer-overlay,
.drawer-leave-active .drawer-overlay {
transition: opacity 0.3s ease;
}
.drawer-enter-from .drawer-overlay,
.drawer-leave-to .drawer-overlay {
opacity: 0;
}
</style>

View File

@ -0,0 +1,38 @@
import { computed, type Ref } from "vue";
interface MediaItem {
id: number;
title: string;
content: string;
url: string;
thumb_url: string;
meta: string;
take_time: string;
width?: number;
height?: number;
}
/**
* hooks
* @param photos
* @param gridCount Ref<number> 4
* @returns computed
*/
export function usePhotoColumns(
photos: Ref<MediaItem[]>,
gridCount: number | Ref<number> = 4
) {
const columns = computed<MediaItem[][]>(() => {
const count = typeof gridCount === 'number' ? gridCount : gridCount.value;
const result: MediaItem[][] = Array.from({ length: count }, () => []);
photos.value.forEach((photo, index) => {
const columnIndex = index % count;
result[columnIndex].push(photo);
});
return result;
});
return columns;
}