diff --git a/README.md b/README.md index 7f474c4..cbea7ae 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ PicImpact 是一个摄影师专用的摄影作品展示网站,基于 Next.js - 响应式设计,在 PC 和移动端都有不错的体验,支持暗黑模式。 - 图片存储兼容 S3 API、Cloudflare R2、AList API。 - 图片支持绑定标签,并且可通过标签进行交互,筛选标签下所有图片。 -- 上传图片时会生成 0.3 倍率的压缩图片,以提供加载优化。 +- 支持批量自动化上传,上传图片时会生成 0.3 倍率的压缩图片,以提供加载优化。 - 图片版权信息展示和维护功能,支持外链跳转。 - 后台有图片数据统计、图片上传、图片维护、相册管理、系统设置和存储配置功能。 - 基于 SSR 的混合渲染,采用状态机制,提供良好的使用体验。 diff --git a/components/MasonryItem.tsx b/components/MasonryItem.tsx index f70e25b..f0985a9 100644 --- a/components/MasonryItem.tsx +++ b/components/MasonryItem.tsx @@ -6,8 +6,8 @@ import { } from '~/components/ui/Dialog' import { useButtonStore } from '~/app/providers/button-store-Providers' import { CopyrightType, DataProps, ImageType } from '~/types' -import { Image, Tabs, Tab, Card, CardHeader, CardBody, CardFooter, Table, TableHeader, TableColumn, TableBody, TableRow, TableCell, Button, Chip, Link, Avatar } from '@nextui-org/react' -import { Aperture, Camera, Image as ImageIcon, Languages, CalendarDays, X, SunMedium, MoonStar, Copyright } from 'lucide-react' +import { Image, Tabs, Tab, Card, CardHeader, CardBody, CardFooter, Button, Chip, Link, Avatar } from '@nextui-org/react' +import { Aperture, Camera, Image as ImageIcon, Languages, CalendarDays, X, SunMedium, MoonStar, Copyright, Crosshair, Timer, CircleGauge } from 'lucide-react' import * as React from 'react' import { useTheme } from 'next-themes' import { useRouter } from 'next-nprogress-bar' @@ -89,15 +89,36 @@ export default function MasonryItem() { } >
- - -
- -

相机

-
-

{MasonryViewData?.exif?.model || 'N&A'}

-
-
+ {MasonryViewData?.exif?.model && MasonryViewData?.exif?.f_number + && MasonryViewData?.exif?.exposure_time && MasonryViewData?.exif?.focal_length + && MasonryViewData?.exif?.iso_speed_rating && + + +
+ +

{MasonryViewData?.exif?.model}

+
+
+
+ +

{MasonryViewData?.exif?.f_number}

+
+
+ +

{MasonryViewData?.exif?.exposure_time}

+
+
+ +

{MasonryViewData?.exif?.focal_length}

+
+
+ +

ISO {MasonryViewData?.exif?.iso_speed_rating}

+
+
+
+
+ }
diff --git a/components/admin/upload/FileUpload.tsx b/components/admin/upload/FileUpload.tsx index 087a0ee..619c2a6 100644 --- a/components/admin/upload/FileUpload.tsx +++ b/components/admin/upload/FileUpload.tsx @@ -11,6 +11,10 @@ import { Button, Select, SelectItem, Input, Divider, Card, CardBody, CardHeader, import ExifReader from 'exifreader' import Compressor from 'compressorjs' import { TagInput } from '@douyinfe/semi-ui' +import { Select as ShadcnSelect, SelectContent, SelectItem as ShadcnSelectItem, SelectTrigger, SelectValue } from '~/components/ui/Select' +import { useButtonStore } from '~/app/providers/button-store-Providers' +import { CircleHelp } from 'lucide-react' +import FileUploadHelpSheet from '~/components/admin/upload/FileUploadHelpSheet' export default function FileUpload() { const [alistStorage, setAlistStorage] = useState([]) @@ -29,6 +33,10 @@ export default function FileUpload() { const [lon, setLon] = useState('') const [detail, setDetail] = useState('') const [imageLabels, setImageLabels] = useState([] as string[]) + const [mode, setMode] = useState('singleton') + const { setUploadHelp } = useButtonStore( + (state) => state, + ) const { data, isLoading } = useSWR('/api/v1/get-tags', fetcher) @@ -216,7 +224,104 @@ export default function FileUpload() { }).then((res) => res.json()) } - async function uploadPreviewImage(option: any, type: string) { + async function autoSubmit(file: any, url: string, previewUrl: string) { + try { + if (mode === 'multiple') { + const tagArray = Array.from(tag) + if (tagArray.length === 0 || tagArray[0] === '') { + toast.warning('请先选择相册!') + return + } + const tags = await ExifReader.load(file) + const exifObj = { + make: '', + model: '', + bits: '', + data_time: '', + exposure_time: '', + f_number: '', + exposure_program: '', + iso_speed_rating: '', + focal_length: '', + lens_specification: '', + lens_model: '', + exposure_mode: '', + cfa_pattern: '', + color_space: '', + white_balance: '', + } as ExifType + exifObj.make = tags?.Make?.description + exifObj.model = tags?.Model?.description + exifObj.bits = tags?.['Bits Per Sample']?.description + exifObj.data_time = tags?.DateTime?.description + exifObj.exposure_time = tags?.ExposureTime?.description + exifObj.f_number = tags?.FNumber?.description + exifObj.exposure_program = tags?.ExposureProgram?.description + exifObj.iso_speed_rating = tags?.ISOSpeedRatings?.description + exifObj.focal_length = tags?.FocalLength?.description + exifObj.lens_specification = tags?.LensSpecification?.description + exifObj.lens_model = tags?.LensModel?.description + exifObj.exposure_mode = tags?.ExposureMode?.description + exifObj.cfa_pattern = tags?.CFAPattern?.description + exifObj.color_space = tags?.ColorSpace?.description + exifObj.white_balance = tags?.WhiteBalance?.description + if (tags?.GPSLatitude?.description) { + setLat(tags?.GPSLatitude?.description) + } else { + setLat('') + } + if (tags?.GPSLongitude?.description) { + setLon(tags?.GPSLongitude?.description) + } else { + setLon('') + } + try { + const reader = new FileReader(); + reader.onload = (e) => { + const img = new Image(); + img.onload = async () => { + const data = { + tag: tagArray[0], + url: url, + title: '', + preview_url: previewUrl, + exif: exifObj, + labels: [], + detail: '', + width: Number(img.width), + height: Number(img.height), + lat: '', + lon: '', + } as ImageType + const res = await fetch('/api/v1/image-add', { + headers: { + 'Content-Type': 'application/json', + }, + method: 'post', + // @ts-ignore + body: JSON.stringify(data), + }).then(res => res.json()) + console.log(res) + if (res?.code === 200) { + toast.success('保存成功!') + } else { + toast.error('保存失败!') + } + }; + // @ts-ignore + img.src = e.target.result; + }; + reader.readAsDataURL(file); + } catch (e) { + console.log(e) + } + } + } catch (e) { + console.log(e) + } + } + + async function uploadPreviewImage(option: any, type: string, url: string) { new Compressor(option.file, { quality: 0.3, checkOrientation: false, @@ -228,6 +333,7 @@ export default function FileUpload() { toast.success('预览图片上传成功!') option.onSuccess(option.file) setPreviewUrl(res?.data) + await autoSubmit(option.file, url, res?.data) } else { toast.error('预览图片上传失败!') } @@ -240,6 +346,7 @@ export default function FileUpload() { toast.success('预览图片上传成功!') option.onSuccess(option.file) setPreviewUrl(res?.data) + await autoSubmit(option.file, url, res?.data) } else { toast.error('预览图片上传失败!') } @@ -259,44 +366,52 @@ export default function FileUpload() { toast.success('图片上传成功,尝试生成预览图片并上传!') try { if (tagArray[0] === '/') { - await uploadPreviewImage(option, '/preview') + await uploadPreviewImage(option, '/preview', res?.data) } else { - await uploadPreviewImage(option, tagArray[0] + '/preview') + await uploadPreviewImage(option, tagArray[0] + '/preview', res?.data) } } catch (e) { console.log(e) option.onSuccess(option.file) } - await loadExif(option.file) - setUrl(res?.data) + if (mode === 'singleton') { + await loadExif(option.file) + setUrl(res?.data) + } } else { option.onError(option.file) toast.error('图片上传失败!') } } + async function onRemoveFile() { + setStorageSelect(false) + setStorage(new Set([] as string[])) + setTag(new Set([] as string[])) + setAlistMountPath(new Set([] as string[])) + setExif({} as ExifType) + setUrl('') + setTitle('') + setDetail('') + setWidth(0) + setHeight(0) + setLat('') + setLon('') + setPreviewUrl('') + setImageLabels([]) + } + const props: UploadProps = { listType: "picture", name: 'file', - multiple: false, - maxCount: 1, + multiple: mode === 'multiple', + maxCount: mode === 'singleton' ? 1 : 5, customRequest: (file) => onRequestUpload(file), beforeUpload: async (file) => await onBeforeUpload(file), - onRemove: (file) => { - setStorageSelect(false) - setStorage(new Set([] as string[])) - setTag(new Set([] as string[])) - setAlistMountPath(new Set([] as string[])) - setExif({} as ExifType) - setUrl('') - setTitle('') - setDetail('') - setWidth(0) - setHeight(0) - setLat('') - setLon('') - setPreviewUrl('') - setImageLabels([]) + onRemove: async (file) => { + if (mode === 'singleton') { + await onRemoveFile() + } } } @@ -305,20 +420,59 @@ export default function FileUpload() {
-
-

上传

-
+ { + setMode(value) + }} + > + + + + + + 单文件上传 + + + 多文件上传 + + + +
+
+ + {mode === 'singleton' + ? + : + + }
-
@@ -416,7 +570,7 @@ export default function FileUpload() { { - url && url !== '' && + url && url !== '' && mode === 'singleton' &&
+
) } \ No newline at end of file diff --git a/components/admin/upload/FileUploadHelpSheet.tsx b/components/admin/upload/FileUploadHelpSheet.tsx new file mode 100644 index 0000000..34a3e69 --- /dev/null +++ b/components/admin/upload/FileUploadHelpSheet.tsx @@ -0,0 +1,57 @@ +'use client' + +import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '~/components/ui/Sheet' +import { useButtonStore } from '~/app/providers/button-store-Providers' + +export default function FileUploadHelpSheet() { + const {uploadHelp, setUploadHelp} = useButtonStore( + (state) => state, + ) + + return ( + { + if (!open) { + setUploadHelp(false) + } + }} + modal={false} + > + + + 帮助 + +

+ 您在当前页面可以上传图片。 +

+

+ 单文件上传模式: + 选择好存储和相册后,选择文件或拖入文件到上传框,会自动上传文件到对应的存储。 + 同时以 0.3 倍率压缩为 webp 格式,生成一张预览用的图片。 + 同时上传完毕后,您可以编辑图片的一些信息,最后点击保存入库。 +

+

+ 多文件上传模式: + 选择好存储和相册后,选择文件或拖入文件到上传框,会自动上传文件到对应的存储。 + 多文件上传模式下,无法在数据入库之前进行编辑,多文件上传属于全自动化上传,无需手动入库。 + 上传队列最大支持 5 个,上传完毕后您可以将图片从上传队列中删除。 + 重置按钮会重置存储和相册等数据。 +

+

+ 注:文件上传时,会自动获取图片的宽高,请您勿随意更改,否则可能导致前端展示错位。 + 非必填项您可以在图片数据入库后,去图片维护里面进行编辑。 +

+

+ 注:部分云平台,限制了上传请求的主体大小,比如 Vercel 的免费用户限制 6M。 +

+

+ 如您有更多疑问欢迎反馈! +

+
+
+
+
+ ) +} \ No newline at end of file diff --git a/stores/buttonStores.ts b/stores/buttonStores.ts index 4fe5a62..04d08f1 100644 --- a/stores/buttonStores.ts +++ b/stores/buttonStores.ts @@ -23,6 +23,7 @@ export type ButtonState = { MasonryViewData: ImageType tagHelp: boolean imageHelp: boolean + uploadHelp: boolean } export type ButtonActions = { @@ -46,6 +47,7 @@ export type ButtonActions = { setMasonryViewData: (masonryViewData: ImageType) => void setTagHelp: (tagHelp: boolean) => void setImageHelp: (imageHelp: boolean) => void + setUploadHelp: (uploadHelp: boolean) => void } export type ButtonStore = ButtonState & ButtonActions @@ -72,6 +74,7 @@ export const initButtonStore = (): ButtonState => { MasonryViewData: {} as ImageType, tagHelp: false, imageHelp: false, + uploadHelp: false, } } @@ -96,6 +99,7 @@ export const defaultInitState: ButtonState = { MasonryViewData: {} as ImageType, tagHelp: false, imageHelp: false, + uploadHelp: false, } export const createButtonStore = ( @@ -165,6 +169,9 @@ export const createButtonStore = ( setImageHelp: (imageHelpValue) => set(() => ({ imageHelp: imageHelpValue, })), + setUploadHelp: (uploadHelpValue) => set(() => ({ + uploadHelp: uploadHelpValue, + })), }), { name: 'pic-impact-button-storage', // name of the item in the storage (must be unique)