This commit is contained in:
@kirill.komarov
2026-05-10 17:38:04 +05:00
parent df4435dd67
commit 20096c1eec
12 changed files with 361 additions and 1 deletions
@@ -0,0 +1,11 @@
import type { GalleryImageItem } from '@/entities/gallery/model/types'
import { apiClient } from '@/shared/api/client'
export async function fetchAdminGallery(): Promise<{ items: GalleryImageItem[] }> {
const { data } = await apiClient.get<{ items: GalleryImageItem[] }>('admin/gallery')
return data
}
export async function deleteGalleryImage(id: string): Promise<void> {
await apiClient.delete(`admin/gallery/${id}`)
}
@@ -0,0 +1,5 @@
export type GalleryImageItem = {
id: string
url: string
createdAt: string
}
+1
View File
@@ -0,0 +1 @@
export { AdminGalleryPage } from './ui/AdminGalleryPage'
@@ -0,0 +1,138 @@
import { useRef } from 'react'
import DeleteOutlineOutlinedIcon from '@mui/icons-material/DeleteOutlineOutlined'
import Box from '@mui/material/Box'
import Button from '@mui/material/Button'
import IconButton from '@mui/material/IconButton'
import Stack from '@mui/material/Stack'
import Tooltip from '@mui/material/Tooltip'
import Typography from '@mui/material/Typography'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { deleteGalleryImage, fetchAdminGallery } from '@/entities/gallery/api/gallery-api'
import { uploadAdminProductImages } from '@/entities/product/api/product-api'
import { invalidateQueryKeys } from '@/shared/lib/invalidate-query-keys'
export function AdminGalleryPage() {
const queryClient = useQueryClient()
const fileInputRef = useRef<HTMLInputElement>(null)
const galleryQuery = useQuery({
queryKey: ['admin', 'gallery'],
queryFn: fetchAdminGallery,
})
const uploadMut = useMutation({
mutationFn: (files: File[]) => uploadAdminProductImages(files),
onSuccess: () => {
void invalidateQueryKeys(queryClient, [['admin', 'gallery']])
if (fileInputRef.current) {
fileInputRef.current.value = ''
}
},
})
const deleteMut = useMutation({
mutationFn: (id: string) => deleteGalleryImage(id),
onSuccess: () => {
void invalidateQueryKeys(queryClient, [['admin', 'gallery']])
},
})
const items = galleryQuery.data?.items ?? []
return (
<Box>
<Typography variant="h4" gutterBottom>
Галерея
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Изображения без привязки к товару можно загружать здесь; их же можно добавить в карточку товара через «Из
галереи». Удаление из списка стирает файл с диска, если оно не используется в товаре.
</Typography>
<Stack direction="row" spacing={2} sx={{ mb: 3, alignItems: 'center', flexWrap: 'wrap' }}>
<Button variant="contained" component="label" disabled={uploadMut.isPending}>
Загрузить файлы
<input
ref={fileInputRef}
hidden
type="file"
accept="image/png,image/jpeg,image/webp"
multiple
onChange={(e) => {
const files = e.target.files
if (!files?.length) return
uploadMut.mutate(Array.from(files))
}}
/>
</Button>
{uploadMut.isPending && <Typography color="text.secondary">Загрузка</Typography>}
{uploadMut.isError && (
<Typography color="error">
{uploadMut.error instanceof Error ? uploadMut.error.message : 'Ошибка загрузки'}
</Typography>
)}
{deleteMut.isError && (
<Typography color="error">
{deleteMut.error instanceof Error ? deleteMut.error.message : 'Ошибка удаления'}
</Typography>
)}
</Stack>
{galleryQuery.isError && (
<Typography color="error" sx={{ mb: 2 }}>
Не удалось загрузить список.
</Typography>
)}
<Box
sx={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(140px, 1fr))',
gap: 2,
}}
>
{items.map((item) => (
<Box
key={item.id}
sx={{
position: 'relative',
borderRadius: 1,
overflow: 'hidden',
border: 1,
borderColor: 'divider',
aspectRatio: '1',
}}
>
<Box
component="img"
src={item.url}
alt=""
sx={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
/>
<Tooltip title="Удалить из галереи">
<IconButton
size="small"
color="error"
sx={{
position: 'absolute',
top: 4,
right: 4,
bgcolor: 'background.paper',
'&:hover': { bgcolor: 'error.light', color: 'error.contrastText' },
}}
disabled={deleteMut.isPending}
onClick={() => deleteMut.mutate(item.id)}
>
<DeleteOutlineOutlinedIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
))}
</Box>
{!galleryQuery.isLoading && items.length === 0 && (
<Typography color="text.secondary">Пока нет загруженных изображений.</Typography>
)}
</Box>
)
}
@@ -4,6 +4,7 @@ import AdminPanelSettingsOutlinedIcon from '@mui/icons-material/AdminPanelSettin
import AssignmentOutlinedIcon from '@mui/icons-material/AssignmentOutlined' import AssignmentOutlinedIcon from '@mui/icons-material/AssignmentOutlined'
import DescriptionOutlinedIcon from '@mui/icons-material/DescriptionOutlined' import DescriptionOutlinedIcon from '@mui/icons-material/DescriptionOutlined'
import PeopleOutlinedIcon from '@mui/icons-material/PeopleOutlined' import PeopleOutlinedIcon from '@mui/icons-material/PeopleOutlined'
import PhotoLibraryOutlinedIcon from '@mui/icons-material/PhotoLibraryOutlined'
import RateReviewOutlinedIcon from '@mui/icons-material/RateReviewOutlined' import RateReviewOutlinedIcon from '@mui/icons-material/RateReviewOutlined'
import StorefrontOutlinedIcon from '@mui/icons-material/StorefrontOutlined' import StorefrontOutlinedIcon from '@mui/icons-material/StorefrontOutlined'
import Badge from '@mui/material/Badge' import Badge from '@mui/material/Badge'
@@ -24,6 +25,7 @@ import { useUnit } from 'effector-react'
import { Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-dom' import { Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-dom'
import { fetchAdminOrdersSummary } from '@/entities/order/api/admin-order-api' import { fetchAdminOrdersSummary } from '@/entities/order/api/admin-order-api'
import { AdminPage } from '@/pages/admin' import { AdminPage } from '@/pages/admin'
import { AdminGalleryPage } from '@/pages/admin-gallery'
import { AdminInfoPage } from '@/pages/admin-info' import { AdminInfoPage } from '@/pages/admin-info'
import { AdminOrdersPage } from '@/pages/admin-orders' import { AdminOrdersPage } from '@/pages/admin-orders'
import { AdminReviewsPage } from '@/pages/admin-reviews' import { AdminReviewsPage } from '@/pages/admin-reviews'
@@ -58,6 +60,7 @@ export function AdminLayoutPage() {
const navItems: NavItem[] = useMemo( const navItems: NavItem[] = useMemo(
() => [ () => [
{ to: '/admin', label: 'Товары', icon: <StorefrontOutlinedIcon /> }, { to: '/admin', label: 'Товары', icon: <StorefrontOutlinedIcon /> },
{ to: '/admin/gallery', label: 'Галерея', icon: <PhotoLibraryOutlinedIcon /> },
{ to: '/admin/orders', label: 'Заказы', icon: <AssignmentOutlinedIcon /> }, { to: '/admin/orders', label: 'Заказы', icon: <AssignmentOutlinedIcon /> },
{ to: '/admin/reviews', label: 'Отзывы', icon: <RateReviewOutlinedIcon /> }, { to: '/admin/reviews', label: 'Отзывы', icon: <RateReviewOutlinedIcon /> },
{ to: '/admin/users', label: 'Пользователи', icon: <PeopleOutlinedIcon /> }, { to: '/admin/users', label: 'Пользователи', icon: <PeopleOutlinedIcon /> },
@@ -183,6 +186,7 @@ export function AdminLayoutPage() {
<Box sx={{ flexGrow: 1, minWidth: 0, width: '100%' }}> <Box sx={{ flexGrow: 1, minWidth: 0, width: '100%' }}>
<Routes> <Routes>
<Route index element={<AdminPage />} /> <Route index element={<AdminPage />} />
<Route path="gallery" element={<AdminGalleryPage />} />
<Route path="orders" element={<AdminOrdersPage />} /> <Route path="orders" element={<AdminOrdersPage />} />
<Route path="reviews" element={<AdminReviewsPage />} /> <Route path="reviews" element={<AdminReviewsPage />} />
<Route path="users" element={<AdminUsersPage />} /> <Route path="users" element={<AdminUsersPage />} />
+123 -1
View File
@@ -2,12 +2,14 @@ import { useRef, useState } from 'react'
import Alert from '@mui/material/Alert' import Alert from '@mui/material/Alert'
import Box from '@mui/material/Box' import Box from '@mui/material/Box'
import Button from '@mui/material/Button' import Button from '@mui/material/Button'
import Checkbox from '@mui/material/Checkbox'
import Dialog from '@mui/material/Dialog' import Dialog from '@mui/material/Dialog'
import DialogActions from '@mui/material/DialogActions' import DialogActions from '@mui/material/DialogActions'
import DialogContent from '@mui/material/DialogContent' import DialogContent from '@mui/material/DialogContent'
import DialogTitle from '@mui/material/DialogTitle' import DialogTitle from '@mui/material/DialogTitle'
import FormControl from '@mui/material/FormControl' import FormControl from '@mui/material/FormControl'
import FormControlLabel from '@mui/material/FormControlLabel' import FormControlLabel from '@mui/material/FormControlLabel'
import FormHelperText from '@mui/material/FormHelperText'
import InputLabel from '@mui/material/InputLabel' import InputLabel from '@mui/material/InputLabel'
import MenuItem from '@mui/material/MenuItem' import MenuItem from '@mui/material/MenuItem'
import Select from '@mui/material/Select' import Select from '@mui/material/Select'
@@ -22,6 +24,7 @@ import TextField from '@mui/material/TextField'
import Typography from '@mui/material/Typography' import Typography from '@mui/material/Typography'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Controller, useForm } from 'react-hook-form' import { Controller, useForm } from 'react-hook-form'
import { fetchAdminGallery } from '@/entities/gallery/api/gallery-api'
import { import {
createCategory, createCategory,
createProduct, createProduct,
@@ -72,6 +75,8 @@ export function AdminPage() {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const { dialogOpen, editing, openCreateDialog, openEditDialog, closeDialog } = useEditDialogState<Product>() const { dialogOpen, editing, openCreateDialog, openEditDialog, closeDialog } = useEditDialogState<Product>()
const [catOpen, setCatOpen] = useState(false) const [catOpen, setCatOpen] = useState(false)
const [galleryPickOpen, setGalleryPickOpen] = useState(false)
const [gallerySelectedUrls, setGallerySelectedUrls] = useState<Set<string>>(() => new Set())
const productForm = useForm<FormState>({ const productForm = useForm<FormState>({
defaultValues: emptyForm(), defaultValues: emptyForm(),
@@ -97,6 +102,12 @@ export function AdminPage() {
queryFn: fetchAdminProducts, queryFn: fetchAdminProducts,
}) })
const galleryForPickQuery = useQuery({
queryKey: ['admin', 'gallery'],
queryFn: fetchAdminGallery,
enabled: galleryPickOpen,
})
const openCreate = () => { const openCreate = () => {
productForm.reset(emptyForm()) productForm.reset(emptyForm())
openCreateDialog() openCreateDialog()
@@ -253,6 +264,31 @@ export function AdminPage() {
) )
} }
const toggleGalleryPickUrl = (url: string) => {
setGallerySelectedUrls((prev) => {
const next = new Set(prev)
if (next.has(url)) {
next.delete(url)
} else {
next.add(url)
}
return next
})
}
const appendGalleryUrlsToForm = () => {
const current = productForm.getValues('imageUrls')
const merged = [...current]
for (const url of gallerySelectedUrls) {
if (!merged.includes(url)) {
merged.push(url)
}
}
productForm.setValue('imageUrls', merged, { shouldDirty: true })
setGalleryPickOpen(false)
setGallerySelectedUrls(new Set())
}
return ( return (
<Box> <Box>
<Typography variant="h4" gutterBottom> <Typography variant="h4" gutterBottom>
@@ -371,15 +407,19 @@ export function AdminPage() {
render={({ field }) => <TextField label="Цена, ₽" fullWidth {...field} />} render={({ field }) => <TextField label="Цена, ₽" fullWidth {...field} />}
/> />
<Box> <Box>
<Typography variant="subtitle2" sx={{ mb: 1 }}> <Typography variant="subtitle2" sx={{ mb: 0.5 }}>
Фото (загрузка) Фото (загрузка)
</Typography> </Typography>
<FormHelperText sx={{ mt: 0, mb: 1 }}>
Крестик на превью убирает фото только из карточки; файл остаётся на сервере и в галерее.
</FormHelperText>
<Box <Box
sx={{ sx={{
display: 'flex', display: 'flex',
gap: 2, gap: 2,
alignItems: { sm: 'center' }, alignItems: { sm: 'center' },
flexDirection: { xs: 'column', sm: 'row' }, flexDirection: { xs: 'column', sm: 'row' },
flexWrap: 'wrap',
}} }}
> >
<Button component="label" variant="outlined" disabled={uploadImagesMut.isPending}> <Button component="label" variant="outlined" disabled={uploadImagesMut.isPending}>
@@ -397,6 +437,15 @@ export function AdminPage() {
}} }}
/> />
</Button> </Button>
<Button
variant="outlined"
onClick={() => {
setGallerySelectedUrls(new Set())
setGalleryPickOpen(true)
}}
>
Из галереи
</Button>
{uploadImagesMut.isPending && <Typography color="text.secondary">Загрузка</Typography>} {uploadImagesMut.isPending && <Typography color="text.secondary">Загрузка</Typography>}
{uploadImagesMut.isError && <Typography color="error">Не удалось загрузить фото</Typography>} {uploadImagesMut.isError && <Typography color="error">Не удалось загрузить фото</Typography>}
</Box> </Box>
@@ -428,6 +477,8 @@ export function AdminPage() {
color="error" color="error"
variant="contained" variant="contained"
onClick={() => removeImage(url)} onClick={() => removeImage(url)}
aria-label="Убрать из карточки"
title="Убрать из карточки"
sx={{ sx={{
position: 'absolute', position: 'absolute',
top: 4, top: 4,
@@ -502,6 +553,77 @@ export function AdminPage() {
</DialogActions> </DialogActions>
</Dialog> </Dialog>
<Dialog
open={galleryPickOpen}
onClose={() => {
setGalleryPickOpen(false)
setGallerySelectedUrls(new Set())
}}
fullWidth
maxWidth="sm"
>
<DialogTitle>Изображения из галереи</DialogTitle>
<DialogContent dividers>
{galleryForPickQuery.isLoading && <Typography color="text.secondary">Загрузка списка</Typography>}
{galleryForPickQuery.isError && (
<Alert severity="error">Не удалось загрузить галерею. Попробуйте ещё раз.</Alert>
)}
{galleryForPickQuery.data?.items.length === 0 && !galleryForPickQuery.isLoading && (
<Typography color="text.secondary">В галерее пока нет файлов. Загрузите их в разделе «Галерея».</Typography>
)}
<Box
sx={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(120px, 1fr))',
gap: 1.5,
pt: 1,
}}
>
{(galleryForPickQuery.data?.items ?? []).map((item) => {
const alreadyInCard = productForm.watch('imageUrls').includes(item.url)
return (
<FormControlLabel
key={item.id}
sx={{ m: 0, alignItems: 'flex-start' }}
control={
<Checkbox
checked={alreadyInCard || gallerySelectedUrls.has(item.url)}
disabled={alreadyInCard}
onChange={() => toggleGalleryPickUrl(item.url)}
/>
}
label={
<Box
component="img"
src={item.url}
alt=""
sx={{ width: '100%', maxHeight: 100, objectFit: 'cover', borderRadius: 1, display: 'block' }}
/>
}
/>
)
})}
</Box>
</DialogContent>
<DialogActions>
<Button
onClick={() => {
setGalleryPickOpen(false)
setGallerySelectedUrls(new Set())
}}
>
Отмена
</Button>
<Button
variant="contained"
onClick={appendGalleryUrlsToForm}
disabled={![...gallerySelectedUrls].some((u) => !productForm.watch('imageUrls').includes(u))}
>
Добавить
</Button>
</DialogActions>
</Dialog>
<Dialog open={catOpen} onClose={() => setCatOpen(false)} fullWidth maxWidth="xs"> <Dialog open={catOpen} onClose={() => setCatOpen(false)} fullWidth maxWidth="xs">
<DialogTitle>Новая категория</DialogTitle> <DialogTitle>Новая категория</DialogTitle>
<DialogContent> <DialogContent>
@@ -0,0 +1,9 @@
-- CreateTable
CREATE TABLE "GalleryImage" (
"id" TEXT NOT NULL PRIMARY KEY,
"url" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- CreateIndex
CREATE UNIQUE INDEX "GalleryImage_url_key" ON "GalleryImage"("url");
+7
View File
@@ -55,6 +55,13 @@ model ProductImage {
@@index([productId, sort]) @@index([productId, sort])
} }
/// Медиатека админки: зарегистрированные файлы /uploads/... (без обязательной привязки к товару).
model GalleryImage {
id String @id @default(cuid())
url String @unique
createdAt DateTime @default(now())
}
model User { model User {
id String @id @default(cuid()) id String @id @default(cuid())
email String @unique email String @unique
+12
View File
@@ -0,0 +1,12 @@
import { prisma } from './prisma.js'
/** Регистрация загруженных путей в медиатеке (идемпотентно). */
export async function upsertGalleryImagesByUrls(urls) {
for (const url of urls) {
await prisma.galleryImage.upsert({
where: { url },
create: { url },
update: {},
})
}
}
+2
View File
@@ -3,6 +3,7 @@ import {
parseMaterialsInput, parseMaterialsInput,
slugify, slugify,
} from './api/_product-helpers.js' } from './api/_product-helpers.js'
import { registerAdminGalleryRoutes } from './api/admin-gallery.js'
import { registerAdminCategoryRoutes } from './api/admin-categories.js' import { registerAdminCategoryRoutes } from './api/admin-categories.js'
import { registerAdminOrderRoutes } from './api/admin-orders.js' import { registerAdminOrderRoutes } from './api/admin-orders.js'
import { registerAdminProductRoutes } from './api/admin-products.js' import { registerAdminProductRoutes } from './api/admin-products.js'
@@ -22,6 +23,7 @@ export async function registerApiRoutes(fastify) {
parseMaterialsInput, parseMaterialsInput,
mapProductForApi, mapProductForApi,
}) })
await registerAdminGalleryRoutes(fastify)
await registerAdminCategoryRoutes(fastify, { slugify }) await registerAdminCategoryRoutes(fastify, { slugify })
await registerAdminOrderRoutes(fastify) await registerAdminOrderRoutes(fastify)
await registerAdminReviewRoutes(fastify) await registerAdminReviewRoutes(fastify)
+47
View File
@@ -0,0 +1,47 @@
import fs from 'node:fs/promises'
import path from 'node:path'
import { prisma } from '../../lib/prisma.js'
export async function registerAdminGalleryRoutes(fastify) {
fastify.get(
'/api/admin/gallery',
{ preHandler: [fastify.verifyAdmin] },
async () => {
const items = await prisma.galleryImage.findMany({
orderBy: { createdAt: 'desc' },
})
return { items }
},
)
fastify.delete(
'/api/admin/gallery/:id',
{ preHandler: [fastify.verifyAdmin] },
async (request, reply) => {
const { id } = request.params
const row = await prisma.galleryImage.findUnique({ where: { id } })
if (!row) {
return reply.code(404).send({ error: 'Не найдено' })
}
const usedInImages = await prisma.productImage.count({ where: { url: row.url } })
const usedAsLegacy = await prisma.product.count({ where: { imageUrl: row.url } })
if (usedInImages > 0 || usedAsLegacy > 0) {
return reply.code(409).send({ error: 'Изображение используется в карточке товара' })
}
const relative = row.url.replace(/^\//, '')
const filePath = path.join(process.cwd(), relative)
try {
await fs.unlink(filePath)
} catch (err) {
if (err && typeof err === 'object' && 'code' in err && err.code !== 'ENOENT') {
throw err
}
}
await prisma.galleryImage.delete({ where: { id } })
return reply.code(204).send()
},
)
}
+2
View File
@@ -1,3 +1,4 @@
import { upsertGalleryImagesByUrls } from '../../lib/gallery.js'
import { prisma } from '../../lib/prisma.js' import { prisma } from '../../lib/prisma.js'
import { import {
formatFileTooLargeMessage, formatFileTooLargeMessage,
@@ -31,6 +32,7 @@ export async function registerAdminProductRoutes(
maxFiles: 10, maxFiles: 10,
maxFileBytes: getProductImageMaxFileBytes(), maxFileBytes: getProductImageMaxFileBytes(),
}) })
await upsertGalleryImagesByUrls(urls)
return { urls } return { urls }
} catch (error) { } catch (error) {
let message = error instanceof Error ? error.message : 'Не удалось загрузить файлы' let message = error instanceof Error ? error.message : 'Не удалось загрузить файлы'