Feat: Add Settings Modal
This commit is contained in:
parent
240fdedec7
commit
1ea027e4f1
108
src/App.vue
108
src/App.vue
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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;
|
||||
}
|
||||
Loading…
Reference in New Issue